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

View file

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

View file

@ -126,6 +126,7 @@ async def api_get_course_by_id(
async def api_get_course_meta( async def api_get_course_meta(
request: Request, request: Request,
course_uuid: str, course_uuid: str,
with_unpublished_activities: bool = False,
db_session: Session = Depends(get_db_session), db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user), current_user: PublicUser = Depends(get_current_user),
) -> FullCourseReadWithTrail: ) -> FullCourseReadWithTrail:
@ -133,7 +134,7 @@ async def api_get_course_meta(
Get single Course Metadata (chapters, activities) by course_uuid Get single Course Metadata (chapters, activities) by course_uuid
""" """
return await get_course_meta( 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_trails,
get_user_trail_with_orgid, get_user_trail_with_orgid,
remove_course_from_trail, 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( return await add_activity_to_trail(
request, user, activity_uuid, db_session 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, current_user: PublicUser | AnonymousUser,
db_session: Session, db_session: Session,
) -> list[ActivityRead]: ) -> list[ActivityRead]:
statement = select(ChapterActivity).where( # Get activities that are published and belong to the chapter
ChapterActivity.chapter_id == coursechapter_id statement = (
select(Activity)
.join(ChapterActivity)
.where(
ChapterActivity.chapter_id == coursechapter_id,
Activity.published == True
)
) )
activities = db_session.exec(statement).all() activities = db_session.exec(statement).all()
if not activities: if not activities:
raise HTTPException( raise HTTPException(
status_code=404, status_code=404,
detail="No activities found", detail="No published activities found",
) )
# RBAC check # RBAC check

View file

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

View file

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

View file

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

View file

@ -282,6 +282,79 @@ async def add_activity_to_trail(
return trail_read 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( async def add_course_to_trail(
request: Request, request: Request,

View file

@ -4,7 +4,7 @@ import { getAPIUrl, getUriWithOrg } from '@services/config/config'
import Canva from '@components/Objects/Activities/DynamicCanva/DynamicCanva' import Canva from '@components/Objects/Activities/DynamicCanva/DynamicCanva'
import VideoActivity from '@components/Objects/Activities/Video/Video' import VideoActivity from '@components/Objects/Activities/Video/Video'
import { BookOpenCheck, Check, CheckCircle, ChevronDown, ChevronLeft, ChevronRight, FileText, Folder, List, Menu, MoreVertical, UserRoundPen, Video, Layers, ListFilter, ListTree, X, Edit2 } from 'lucide-react' import { BookOpenCheck, Check, CheckCircle, ChevronDown, ChevronLeft, ChevronRight, FileText, Folder, List, Menu, MoreVertical, UserRoundPen, Video, Layers, ListFilter, ListTree, X, Edit2 } 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 DocumentPdfActivity from '@components/Objects/Activities/DocumentPdf/DocumentPdf'
import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators' import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators'
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' 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 { useMediaQuery } from 'usehooks-ts'
import PaidCourseActivityDisclaimer from '@components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer' import PaidCourseActivityDisclaimer from '@components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer'
import { useContributorStatus } from '../../../../../../../../hooks/useContributorStatus' 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 { interface ActivityClientProps {
activityid: string activityid: string
@ -91,159 +96,175 @@ function ActivityClient(props: ActivityClientProps) {
<CourseProvider courseuuid={course?.course_uuid}> <CourseProvider courseuuid={course?.course_uuid}>
<AIChatBotProvider> <AIChatBotProvider>
<GeneralWrapperStyled> <GeneralWrapperStyled>
<div className="space-y-4 pt-4"> {activityid === 'end' ? (
<div className="flex justify-between items-center"> <CourseEndView
<div className="flex space-x-6"> courseName={course.name}
<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}
orgslug={orgslug} orgslug={orgslug}
course={course} courseUuid={course.course_uuid}
thumbnailImage={course.thumbnail_image}
/> />
) : (
<div className="flex justify-between items-center"> <div className="space-y-4 pt-0">
<div className="flex items-center space-x-3"> <div className="pt-2">
<ActivityChapterDropdown <div className="space-y-4 pb-4 activity-info-section">
course={course} <div className="flex justify-between items-center">
currentActivityId={activity.activity_uuid ? activity.activity_uuid.replace('activity_', '') : activityid.replace('activity_', '')} <div className="flex space-x-6">
orgslug={orgslug} <div className="flex">
/> <Link
<div className="flex flex-col -space-y-1"> href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}`}
<p className="font-bold text-gray-700 text-md"> >
Chapter : {getChapterNameByActivityId(course, activity.id)} <img
</p> className="w-[100px] h-[57px] rounded-md drop-shadow-md"
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase"> src={`${getCourseThumbnailMediaDirectory(
{activity.name} org?.org_uuid,
</h1> course.course_uuid,
</div> course.thumbnail_image
</div> )}`}
<div className="flex space-x-2 items-center"> alt=""
{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}
/> />
</AssignmentSubmissionProvider> </Link>
</> </div>
} <div className="flex flex-col -space-y-1">
</AuthenticatedClientElement> <p className="font-bold text-gray-700 text-md">Course </p>
)} <h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
</div> {course.name}
</div> </h1>
{activity && activity.published == false && ( </div>
<div className="p-7 drop-shadow-xs rounded-lg bg-gray-800"> </div>
<div className="text-white"> </div>
<h1 className="font-bold text-2xl">
This activity is not published yet
</h1>
</div>
</div>
)}
{activity && activity.published == true && ( <ActivityIndicators
<> course_uuid={courseuuid}
{activity.content.paid_access == false ? ( current_activity={activityid}
<PaidCourseActivityDisclaimer course={course} /> orgslug={orgslug}
) : ( course={course}
<div className={`p-7 drop-shadow-xs rounded-lg ${bgColor}`}> />
{/* Activity Types */}
<div> <div className="flex justify-between items-center">
{activity.activity_type == 'TYPE_DYNAMIC' && ( <div className="flex items-center space-x-3">
<Canva content={activity.content} activity={activity} /> <ActivityChapterDropdown
)} course={course}
{activity.activity_type == 'TYPE_VIDEO' && ( currentActivityId={activity.activity_uuid ? activity.activity_uuid.replace('activity_', '') : activityid.replace('activity_', '')}
<VideoActivity course={course} activity={activity} /> orgslug={orgslug}
)} />
{activity.activity_type == 'TYPE_DOCUMENT' && ( <div className="flex flex-col -space-y-1">
<DocumentPdfActivity <p className="font-bold text-gray-700 text-md">
course={course} Chapter : {getChapterNameByActivityId(course, activity.id)}
activity={activity} </p>
/> <h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
)} {activity.name}
{activity.activity_type == 'TYPE_ASSIGNMENT' && ( </h1>
<div> </div>
{assignment ? ( </div>
<AssignmentProvider assignment_uuid={assignment?.assignment_uuid}> <div className="flex space-x-2 items-center">
<AssignmentsTaskProvider> {activity && activity.published == true && activity.content.paid_access != false && (
<AssignmentSubmissionProvider assignment_uuid={assignment?.assignment_uuid}> <AuthenticatedClientElement checkMethod="authentication">
<AssignmentStudentActivity /> {activity.activity_type != 'TYPE_ASSIGNMENT' && (
</AssignmentSubmissionProvider> <>
</AssignmentsTaskProvider> <AIActivityAsk activity={activity} />
</AssignmentProvider> {contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && (
) : ( <Link
<div></div> 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> </div>
</div>
{activity && activity.published == false && (
<div className="p-7 drop-shadow-xs rounded-lg bg-gray-800">
<div className="text-white">
<h1 className="font-bold text-2xl">
This activity is not published yet
</h1>
</div>
</div>
)} )}
</>
)} {activity && activity.published == true && (
<>
{/* Activity Navigation */} {activity.content.paid_access == false ? (
{activity && activity.published == true && activity.content.paid_access != false && ( <PaidCourseActivityDisclaimer course={course} />
<ActivityNavigation ) : (
course={course} <div className={`p-7 drop-shadow-xs rounded-lg ${bgColor}`}>
currentActivityId={activity.activity_uuid ? activity.activity_uuid.replace('activity_', '') : activityid.replace('activity_', '')} {/* Activity Types */}
orgslug={orgslug} <div>
/> {activity.activity_type == 'TYPE_DYNAMIC' && (
)} <Canva content={activity.content} activity={activity} />
)}
{<div style={{ height: '100px' }}></div>} {activity.activity_type == 'TYPE_VIDEO' && (
</div> <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>
)}
</>
)}
{/* 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> </GeneralWrapperStyled>
</AIChatBotProvider> </AIChatBotProvider>
</CourseProvider> </CourseProvider>
@ -262,10 +283,73 @@ export function MarkStatus(props: {
const isMobile = useMediaQuery('(max-width: 768px)') const isMobile = useMediaQuery('(max-width: 768px)')
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const 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() { async function markActivityAsCompleteFront() {
try { try {
// Check if this will be the last activity to complete
const willCompleteAll = areAllActivitiesCompleted();
console.log('Will complete all?', willCompleteAll);
setIsLoading(true); 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.orgslug,
props.course.course_uuid, props.course.course_uuid,
props.activity.activity_uuid, props.activity.activity_uuid,
@ -276,7 +360,7 @@ export function MarkStatus(props: {
await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`); await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`);
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
toast.error('Failed to mark activity as complete'); toast.error('Failed to unmark activity as complete');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -296,36 +380,121 @@ export function MarkStatus(props: {
return ( return (
<> <>
{isActivityCompleted() ? ( {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"> <div className="flex items-center space-x-2">
<i> <div className="bg-teal-600 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white">
<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>
) : (
<i> <i>
<Check size={17}></Check> <Check size={17}></Check>
</i> </i>{' '}
)}{' '} <i className="not-italic text-xs font-bold">Complete</i>
{!isMobile && <i className="not-italic text-xs font-bold">{isLoading ? 'Marking...' : 'Mark as 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> </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: { function AssignmentTools(props: {
activity: any activity: any
activityid: string activityid: string
@ -442,335 +611,4 @@ function AssignmentTools(props: {
return null 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 export default ActivityClient

View file

@ -25,7 +25,7 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
return ( return (
<div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto_1fr]"> <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"> <div className="pl-10 pr-10 text-sm tracking-tight bg-[#fcfbfc] z-10 nice-shadow">
<CourseOverviewTop params={params} /> <CourseOverviewTop params={params} />
<div className="flex space-x-3 font-black text-sm"> <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 CourseContext = createContext(null)
export const CourseDispatchContext = 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 session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token; 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) url => swrFetcher(url, access_token)
); );
@ -22,7 +22,8 @@ export function CourseProvider({ children, courseuuid }: any) {
}, },
courseOrder: {}, courseOrder: {},
isSaved: true, isSaved: true,
isLoading: true isLoading: true,
withUnpublishedActivities: withUnpublishedActivities
}; };
const [state, dispatch] = useReducer(courseReducer, initialState) as any; const [state, dispatch] = useReducer(courseReducer, initialState) as any;

View file

@ -6,37 +6,43 @@ import {
useCourse, useCourse,
useCourseDispatch, useCourseDispatch,
} from '@components/Contexts/CourseContext' } 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 { useRouter } from 'next/navigation'
import React, { useEffect } from 'react' import React, { useEffect, useState } from 'react'
import { mutate } from 'swr' import { mutate } from 'swr'
import { updateCourse } from '@services/courses/courses' import { updateCourse } from '@services/courses/courses'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
function SaveState(props: { orgslug: string }) { function SaveState(props: { orgslug: string }) {
const [isLoading, setIsLoading] = useState(false)
const course = useCourse() as any const course = useCourse() as any
const session = useLHSession() as any; const session = useLHSession() as any;
const router = useRouter() const router = useRouter()
const saved = course ? course.isSaved : true const saved = course ? course.isSaved : true
const dispatchCourse = useCourseDispatch() as any const dispatchCourse = useCourseDispatch() as any
const course_structure = course.courseStructure const course_structure = course.courseStructure
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
const saveCourseState = async () => { const saveCourseState = async () => {
// Course order if (saved || isLoading) return
if (saved) return setIsLoading(true)
await changeOrderBackend() try {
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`) // Course order
// Course metadata await changeOrderBackend()
await changeMetadataBackend() mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`) // Course metadata
await revalidateTags(['courses'], props.orgslug) await changeMetadataBackend()
dispatchCourse({ type: 'setIsSaved' }) 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 // Course Order
const changeOrderBackend = async () => { 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( await updateCourseOrderStructure(
course.courseStructure.course_uuid, course.courseStructure.course_uuid,
course.courseOrder, course.courseOrder,
@ -49,7 +55,7 @@ function SaveState(props: { orgslug: string }) {
// Course metadata // Course metadata
const changeMetadataBackend = async () => { 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( await updateCourse(
course.courseStructure.course_uuid, course.courseStructure.course_uuid,
course.courseStructure, 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 ` + `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 (saved
? 'bg-gray-600 text-white' ? '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} onClick={saveCourseState}
> >
{saved ? <Check size={20} /> : <SaveAllIcon size={20} />} {isLoading ? (
{saved ? <div className="">Saved</div> : <div className="">Save</div>} <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>
</div> </div>
) )

View file

@ -5,111 +5,180 @@ import { updateCourseThumbnail } from '@services/courses/courses'
import { getCourseThumbnailMediaDirectory } from '@services/media/media' import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import { ArrowBigUpDash, UploadCloud, Image as ImageIcon } from 'lucide-react' import { ArrowBigUpDash, UploadCloud, Image as ImageIcon } from 'lucide-react'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import React, { useState } from 'react' import React, { useState, useEffect } from 'react'
import { mutate } from 'swr' import { mutate } from 'swr'
import UnsplashImagePicker from './UnsplashImagePicker' 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() { function ThumbnailUpdate() {
const course = useCourse() as any const course = useCourse() as any
const session = useLHSession() as any; const session = useLHSession() as any;
const org = useOrg() as any const org = useOrg() as any
const [localThumbnail, setLocalThumbnail] = React.useState(null) as any const [localThumbnail, setLocalThumbnail] = useState<{ file: File; url: string } | null>(null)
const [isLoading, setIsLoading] = React.useState(false) as any const [isLoading, setIsLoading] = useState(false)
const [error, setError] = React.useState('') as any const [error, setError] = useState<string>('')
const [showUnsplashPicker, setShowUnsplashPicker] = useState(false) const [showUnsplashPicker, setShowUnsplashPicker] = useState(false)
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
const handleFileChange = async (event: any) => { // Cleanup blob URLs when component unmounts or when thumbnail changes
const file = event.target.files[0] useEffect(() => {
setLocalThumbnail(file) return () => {
await updateThumbnail(file) 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) => { const handleUnsplashSelect = async (imageUrl: string) => {
setIsLoading(true) try {
const response = await fetch(imageUrl) setIsLoading(true);
const blob = await response.blob() const response = await fetch(imageUrl);
const file = new File([blob], 'unsplash_image.jpg', { type: 'image/jpeg' }) const blob = await response.blob();
setLocalThumbnail(file)
await updateThumbnail(file) 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) => { const updateThumbnail = async (file: File) => {
setIsLoading(true) setIsLoading(true);
const res = await updateCourseThumbnail( try {
course.courseStructure.course_uuid, const res = await updateCourseThumbnail(
file, course.courseStructure.course_uuid,
session.data?.tokens?.access_token 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)) await mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`);
if (res.success === false) { await new Promise((r) => setTimeout(r, 1500));
setError(res.HTTPmessage)
} else { if (res.success === false) {
setIsLoading(false) setError(res.HTTPmessage);
setError('') } else {
setError('');
}
} catch (err) {
setError('Failed to update thumbnail');
} finally {
setIsLoading(false);
} }
} }
return ( return (
<div className="w-auto bg-gray-50 rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow-sm"> <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"> <div className="flex flex-col justify-center items-center h-full p-6 space-y-4">
<div className="flex flex-col justify-center items-center"> {error && (
<div className="flex flex-col justify-center items-center"> <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">
{error && ( <div className="text-sm font-medium">{error}</div>
<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> </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"> <div className="flex flex-col items-center space-y-4">
<ArrowBigUpDash size={16} className="mr-2" /> {localThumbnail ? (
<span>Uploading</span> <img
</div> src={localThumbnail.url}
</div> 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 <input
type="file" type="file"
id="fileInput" id="fileInput"
style={{ display: 'none' }} className="hidden"
accept=".jpg,.jpeg,.png"
onChange={handleFileChange} onChange={handleFileChange}
/> />
<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={() => document.getElementById('fileInput')?.click()} onClick={() => document.getElementById('fileInput')?.click()}
> >
<UploadCloud size={16} className="mr-2" /> <UploadCloud size={16} className="mr-2" />
<span>Upload Image</span> Upload
</button> </button>
<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)} onClick={() => setShowUnsplashPicker(true)}
> >
<ImageIcon size={16} className="mr-2" /> <ImageIcon size={16} className="mr-2" />
<span>Choose from Gallery</span> Gallery
</button> </button>
</div> </div>
)} )}
</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> </div>
{showUnsplashPicker && ( {showUnsplashPicker && (
<UnsplashImagePicker <UnsplashImagePicker
onSelect={handleUnsplashSelect} onSelect={handleUnsplashSelect}

View file

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

View file

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

View file

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

View file

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

View file

@ -32,6 +32,7 @@ import TableHeader from '@tiptap/extension-table-header'
import TableRow from '@tiptap/extension-table-row' import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell' import TableCell from '@tiptap/extension-table-cell'
import UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock' import UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock'
import { getLinkExtension } from '@components/Objects/Editor/EditorConf'
interface Editor { interface Editor {
content: string content: string
@ -57,7 +58,18 @@ function Canva(props: Editor) {
const editor: any = useEditor({ const editor: any = useEditor({
editable: isEditable, editable: isEditable,
extensions: [ extensions: [
StarterKit, StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: 'bullet-list',
},
},
orderedList: {
HTMLAttributes: {
class: 'ordered-list',
},
},
}),
NoTextInput, NoTextInput,
// Custom Extensions // Custom Extensions
InfoCallout.configure({ InfoCallout.configure({
@ -112,6 +124,7 @@ function Canva(props: Editor) {
Table.configure({ Table.configure({
resizable: true, resizable: true,
}), }),
getLinkExtension(),
TableRow, TableRow,
TableHeader, TableHeader,
TableCell, TableCell,
@ -194,10 +207,30 @@ const CanvaWrapper = styled.div`
margin-bottom: 10px; 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, ul,
ol { ol {
padding: 0 1rem; padding: 0 1rem;
padding-left: 20px; padding-left: 20px;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal; list-style-type: decimal;
} }

View file

@ -3,17 +3,71 @@ import YouTube from 'react-youtube'
import { getActivityMediaDirectory } from '@services/media/media' import { getActivityMediaDirectory } from '@services/media/media'
import { useOrg } from '@components/Contexts/OrgContext' 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 org = useOrg() as any
const [videoId, setVideoId] = React.useState('') const [videoId, setVideoId] = React.useState('')
const videoRef = React.useRef<HTMLVideoElement>(null)
React.useEffect(() => { React.useEffect(() => {
if (activity && activity.content && activity.content.uri) { if (activity?.content?.uri) {
var getYouTubeID = require('get-youtube-id'); var getYouTubeID = require('get-youtube-id')
setVideoId(getYouTubeID(activity.content.uri)) setVideoId(getYouTubeID(activity.content.uri))
} }
}, [activity, org]) }, [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 ( return (
<div className="w-full max-w-full px-2 sm:px-4"> <div className="w-full max-w-full px-2 sm:px-4">
{activity && ( {activity && (
@ -22,15 +76,12 @@ function VideoActivity({ activity, course }: { activity: any; course: any }) {
<div className="my-3 md:my-5 w-full"> <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"> <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 <video
ref={videoRef}
className="w-full h-full object-cover" className="w-full h-full object-cover"
controls controls
src={getActivityMediaDirectory( src={getVideoSrc()}
org?.org_uuid, onLoadedMetadata={handleVideoLoad}
course?.course_uuid, onTimeUpdate={handleTimeUpdate}
activity.activity_uuid,
activity.content?.filename,
'video'
)}
></video> ></video>
</div> </div>
</div> </div>
@ -44,7 +95,10 @@ function VideoActivity({ activity, course }: { activity: any; course: any }) {
width: '100%', width: '100%',
height: '100%', height: '100%',
playerVars: { 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} videoId={videoId}

View file

@ -32,7 +32,8 @@ import TableRow from '@tiptap/extension-table-row'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
import Link from 'next/link' import Link from 'next/link'
import { getCourseThumbnailMediaDirectory } from '@services/media/media' import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import { getLinkExtension } from './EditorConf'
import { Link as LinkExtension } from '@tiptap/extension-link'
// Lowlight // Lowlight
import { common, createLowlight } from 'lowlight' import { common, createLowlight } from 'lowlight'
@ -95,7 +96,18 @@ function Editor(props: Editor) {
const editor: any = useEditor({ const editor: any = useEditor({
editable: true, editable: true,
extensions: [ extensions: [
StarterKit, StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: 'bullet-list',
},
},
orderedList: {
HTMLAttributes: {
class: 'ordered-list',
},
},
}),
InfoCallout.configure({ InfoCallout.configure({
editable: true, editable: true,
}), }),
@ -151,6 +163,7 @@ function Editor(props: Editor) {
TableRow, TableRow,
TableHeader, TableHeader,
TableCell, TableCell,
getLinkExtension(),
], ],
content: props.content, content: props.content,
immediatelyRender: false, immediatelyRender: false,
@ -204,7 +217,7 @@ function Editor(props: Editor) {
props.org?.org_uuid, props.org?.org_uuid,
props.course.course_uuid, props.course.course_uuid,
props.course.thumbnail_image props.course.thumbnail_image
) : getUriWithOrg(props.org?.slug,'/empty_thumbnail.png')}`} ) : getUriWithOrg(props.org?.slug, '/empty_thumbnail.png')}`}
alt="" alt=""
></EditorInfoThumbnail> ></EditorInfoThumbnail>
</Link> </Link>
@ -459,6 +472,19 @@ export const EditorContentWrapper = styled.div`
margin-bottom: 10px; 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-left: 20px;
padding-right: 20px; padding-right: 20px;
padding-bottom: 20px; padding-bottom: 20px;
@ -564,6 +590,13 @@ export const EditorContentWrapper = styled.div`
ol { ol {
padding: 0 1rem; padding: 0 1rem;
padding-left: 20px; padding-left: 20px;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal; 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, FileText,
ImagePlus, ImagePlus,
Lightbulb, Lightbulb,
Link2,
MousePointerClick, MousePointerClick,
Sigma, Sigma,
Table, Table,
@ -30,30 +31,24 @@ import {
Tags, Tags,
User, User,
Video, Video,
List,
ListOrdered,
} from 'lucide-react' } from 'lucide-react'
import { SiYoutube } from '@icons-pack/react-simple-icons' import { SiYoutube } from '@icons-pack/react-simple-icons'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
import React from 'react' import React from 'react'
import LinkInputTooltip from './LinkInputTooltip'
export const ToolbarButtons = ({ editor, props }: any) => { export const ToolbarButtons = ({ editor, props }: any) => {
const [showTableMenu, setShowTableMenu] = React.useState(false) 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) { if (!editor) {
return null 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 = [ 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 ( return (
<ToolButtonsWrapper> <ToolButtonsWrapper>
<ToolBtn onClick={() => editor.chain().focus().undo().run()}> <ToolBtn onClick={() => editor.chain().focus().undo().run()}>
@ -109,12 +172,32 @@ export const ToolbarButtons = ({ editor, props }: any) => {
> >
<StrikethroughIcon /> <StrikethroughIcon />
</ToolBtn> </ToolBtn>
<ToolBtn <ListMenuWrapper>
onClick={() => editor.chain().focus().toggleOrderedList().run()} <ToolBtn
className={editor.isActive('orderedList') ? 'is-active' : ''} onClick={() => setShowListMenu(!showListMenu)}
> className={showListMenu || editor.isActive('bulletList') || editor.isActive('orderedList') ? 'is-active' : ''}
<ListBulletIcon /> >
</ToolBtn> <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 <ToolSelect
value={ value={
editor.isActive('heading', { level: 1 }) ? "1" : editor.isActive('heading', { level: 1 }) ? "1" :
@ -185,6 +268,24 @@ export const ToolbarButtons = ({ editor, props }: any) => {
<AlertTriangle size={15} /> <AlertTriangle size={15} />
</ToolBtn> </ToolBtn>
</ToolTip> </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'}> <ToolTip content={'Image'}>
<ToolBtn <ToolBtn
onClick={() => onClick={() =>
@ -423,6 +524,51 @@ const TableMenuItem = styled.div`
align-items: center; align-items: center;
} }
.label {
font-size: 12px;
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 { .label {
font-size: 12px; font-size: 12px;
font-family: 'DM Sans'; 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 DocumentPdfPageActivityImage from 'public//activities_types/documentpdf-page-activity.png'
import AssignmentActivityImage from 'public//activities_types/assignment-page-activity.png' import AssignmentActivityImage from 'public//activities_types/assignment-page-activity.png'
import DynamicCanvaModal from './NewActivityModal/DynamicCanva' import DynamicCanvaModal from './NewActivityModal/DynamicActivityModal'
import VideoModal from './NewActivityModal/Video' import VideoModal from './NewActivityModal/VideoActivityModal'
import Image from 'next/image' import Image from 'next/image'
import DocumentPdfModal from './NewActivityModal/DocumentPdf' import DocumentPdfModal from './NewActivityModal/DocumentActivityModal'
import Assignment from './NewActivityModal/Assignment' import Assignment from './NewActivityModal/AssignmentActivityModal'
function NewActivityModal({ function NewActivityModal({
closeModal, 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 ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import { deleteCourseFromBackend } from '@services/courses/courses' 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 { revalidateTags } from '@services/utils/ts/requests'
import { BookMinus, FilePenLine, Settings2, MoreVertical } from 'lucide-react' import { BookMinus, FilePenLine, Settings2, MoreVertical } from 'lucide-react'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
@ -12,6 +12,7 @@ import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import React from 'react' import React from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import UserAvatar from '@components/Objects/UserAvatar'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -25,6 +26,19 @@ type Course = {
description: string description: string
thumbnail_image: string thumbnail_image: string
org_id: 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 = { type PropsType = {
@ -40,6 +54,11 @@ function CourseThumbnail({ course, orgslug, customLink }: PropsType) {
const org = useOrg() as any const org = useOrg() as any
const session = useLHSession() 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 deleteCourse = async () => {
const toastId = toast.loading('Deleting course...') const toastId = toast.loading('Deleting course...')
try { try {
@ -59,7 +78,7 @@ function CourseThumbnail({ course, orgslug, customLink }: PropsType) {
: '../empty_thumbnail.png' : '../empty_thumbnail.png'
return ( 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 <AdminEditOptions
course={course} course={course}
orgSlug={orgslug} orgSlug={orgslug}
@ -67,13 +86,65 @@ function CourseThumbnail({ course, orgslug, customLink }: PropsType) {
/> />
<Link prefetch href={customLink ? customLink : getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)}> <Link prefetch href={customLink ? customLink : getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)}>
<div <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})` }} style={{ backgroundImage: `url(${thumbnailImage})` }}
/> />
</Link> </Link>
<div className='flex flex-col w-full pt-3 space-y-2'> <div className='flex flex-col w-full p-4 space-y-3'>
<h2 className="font-bold text-gray-800 line-clamp-2 leading-tight text-lg capitalize">{course.name}</h2> <div className="space-y-2">
<p className='text-sm text-gray-700 leading-normal line-clamp-3'>{course.description}</p> <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>
</div> </div>
) )

View file

@ -4,7 +4,7 @@ import AuthenticatedClientElement from '@components/Security/AuthenticatedClient
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import { deleteCourseFromBackend } from '@services/courses/courses' 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 { revalidateTags } from '@services/utils/ts/requests'
import { BookMinus, FilePenLine, Settings2, MoreVertical } from 'lucide-react' import { BookMinus, FilePenLine, Settings2, MoreVertical } from 'lucide-react'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
@ -12,6 +12,7 @@ import Link from 'next/link'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import React from 'react' import React from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import UserAvatar from '@components/Objects/UserAvatar'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -26,6 +27,18 @@ type Course = {
thumbnail_image: string thumbnail_image: string
org_id: string org_id: string
update_date: 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 = { type PropsType = {
@ -94,6 +107,11 @@ const CourseThumbnailLanding: React.FC<PropsType> = ({ course, orgslug, customLi
const org = useOrg() as any const org = useOrg() as any
const session = useLHSession() 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 deleteCourse = async () => {
const toastId = toast.loading('Deleting course...') const toastId = toast.loading('Deleting course...')
try { 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> <p className='text-xs text-gray-700 leading-normal min-h-[3.75rem] line-clamp-3'>{course.description}</p>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
{course.update_date && ( {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"> <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"> <span className="text-[10px] font-medium text-gray-600 truncate">
@ -139,6 +157,38 @@ const CourseThumbnailLanding: React.FC<PropsType> = ({ course, orgslug, customLi
</span> </span>
</div> </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> </div>
<Link <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 ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import { deleteActivity, updateActivity } from '@services/courses/activities' import { deleteActivity, updateActivity } from '@services/courses/activities'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useCourse } from '@components/Contexts/CourseContext'
interface ModifiedActivityInterface { interface ModifiedActivityInterface {
activityId: string activityId: string
@ -33,10 +34,12 @@ function Activity(props: any) {
const [selectedActivity, setSelectedActivity] = React.useState< const [selectedActivity, setSelectedActivity] = React.useState<
string | undefined string | undefined
>(undefined) >(undefined)
const course = useCourse() as any;
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
async function removeActivity() { async function removeActivity() {
await deleteActivity(props.activity.id, session.data?.tokens?.access_token) 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) await revalidateTags(['courses'], props.orgslug)
router.refresh() router.refresh()
} }
@ -52,7 +55,7 @@ function Activity(props: any) {
} }
await updateActivity(modifiedActivityCopy, activityId, session.data?.tokens?.access_token) 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) await revalidateTags(['courses'], props.orgslug)
router.refresh() router.refresh()
} }

View file

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

View file

@ -33,7 +33,11 @@
"@stitches/react": "^1.2.8", "@stitches/react": "^1.2.8",
"@tanstack/react-table": "^8.21.2", "@tanstack/react-table": "^8.21.2",
"@tiptap/core": "^2.11.7", "@tiptap/core": "^2.11.7",
"@tiptap/extension-bullet-list": "^2.11.7",
"@tiptap/extension-code-block-lowlight": "^2.11.7", "@tiptap/extension-code-block-lowlight": "^2.11.7",
"@tiptap/extension-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": "^2.11.7",
"@tiptap/extension-table-cell": "^2.11.7", "@tiptap/extension-table-cell": "^2.11.7",
"@tiptap/extension-table-header": "^2.11.7", "@tiptap/extension-table-header": "^2.11.7",

View file

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

View file

@ -39,6 +39,15 @@ export async function createFileActivity(
if (type === 'video') { if (type === 'video') {
formData.append('name', data.name) formData.append('name', data.name)
formData.append('video_file', file) 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` endpoint = `${getAPIUrl()}activities/video`
} else if (type === 'documentpdf') { } else if (type === 'documentpdf') {
formData.append('pdf_file', file) formData.append('pdf_file', file)
@ -65,6 +74,23 @@ export async function createExternalVideoActivity(
// add coursechapter_id to data // add coursechapter_id to data
data.chapter_id = chapter_id data.chapter_id = chapter_id
data.activity_id = activity.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( const result = await fetch(
`${getAPIUrl()}activities/external_video`, `${getAPIUrl()}activities/external_video`,

View file

@ -36,3 +36,17 @@ export async function markActivityAsComplete(
const res = await errorHandling(result) const res = await errorHandling(result)
return res 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
}