mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
Merge pull request #475 from learnhouse/feat/ux-improvements
UX improvements
This commit is contained in:
commit
f299ecb278
43 changed files with 2283 additions and 854 deletions
|
|
@ -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 ###
|
||||
|
|
@ -32,6 +32,7 @@ class ActivityBase(SQLModel):
|
|||
activity_type: ActivityTypeEnum
|
||||
activity_sub_type: ActivitySubTypeEnum
|
||||
content: dict = Field(default={}, sa_column=Column(JSON))
|
||||
details: Optional[dict] = Field(default=None, sa_column=Column(JSON))
|
||||
published: bool = False
|
||||
|
||||
|
||||
|
|
@ -53,6 +54,7 @@ class ActivityCreate(ActivityBase):
|
|||
chapter_id: int
|
||||
activity_type: ActivityTypeEnum = ActivityTypeEnum.TYPE_CUSTOM
|
||||
activity_sub_type: ActivitySubTypeEnum = ActivitySubTypeEnum.SUBTYPE_CUSTOM
|
||||
details: dict = Field(default={}, sa_column=Column(JSON))
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -61,6 +63,7 @@ class ActivityUpdate(ActivityBase):
|
|||
content: dict = Field(default={}, sa_column=Column(JSON))
|
||||
activity_type: Optional[ActivityTypeEnum]
|
||||
activity_sub_type: Optional[ActivitySubTypeEnum]
|
||||
details: Optional[dict] = Field(default=None, sa_column=Column(JSON))
|
||||
published_version: Optional[int]
|
||||
version: Optional[int]
|
||||
|
||||
|
|
@ -72,4 +75,5 @@ class ActivityRead(ActivityBase):
|
|||
activity_uuid: str
|
||||
creation_date: str
|
||||
update_date: str
|
||||
details: Optional[dict] = Field(default=None, sa_column=Column(JSON))
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ async def api_create_video_activity(
|
|||
request: Request,
|
||||
name: str = Form(),
|
||||
chapter_id: str = Form(),
|
||||
details: str = Form(default="{}"),
|
||||
current_user: PublicUser = Depends(get_current_user),
|
||||
video_file: UploadFile | None = None,
|
||||
db_session=Depends(get_db_session),
|
||||
|
|
@ -127,6 +128,7 @@ async def api_create_video_activity(
|
|||
current_user,
|
||||
db_session,
|
||||
video_file,
|
||||
details,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ async def api_get_course_by_id(
|
|||
async def api_get_course_meta(
|
||||
request: Request,
|
||||
course_uuid: str,
|
||||
with_unpublished_activities: bool = False,
|
||||
db_session: Session = Depends(get_db_session),
|
||||
current_user: PublicUser = Depends(get_current_user),
|
||||
) -> FullCourseReadWithTrail:
|
||||
|
|
@ -133,7 +134,7 @@ async def api_get_course_meta(
|
|||
Get single Course Metadata (chapters, activities) by course_uuid
|
||||
"""
|
||||
return await get_course_meta(
|
||||
request, course_uuid, current_user=current_user, db_session=db_session
|
||||
request, course_uuid, with_unpublished_activities, current_user=current_user, db_session=db_session
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from src.services.trail.trail import (
|
|||
get_user_trails,
|
||||
get_user_trail_with_orgid,
|
||||
remove_course_from_trail,
|
||||
remove_activity_from_trail,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -95,3 +96,16 @@ async def api_add_activity_to_trail(
|
|||
return await add_activity_to_trail(
|
||||
request, user, activity_uuid, db_session
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/remove_activity/{activity_uuid}")
|
||||
async def api_remove_activity_from_trail(
|
||||
request: Request,
|
||||
activity_uuid: str,
|
||||
user=Depends(get_current_user),
|
||||
db_session=Depends(get_db_session),
|
||||
) -> TrailRead:
|
||||
"""
|
||||
Remove Activity from trail
|
||||
"""
|
||||
return await remove_activity_from_trail(request, user, activity_uuid, db_session)
|
||||
|
|
|
|||
|
|
@ -260,15 +260,21 @@ async def get_activities(
|
|||
current_user: PublicUser | AnonymousUser,
|
||||
db_session: Session,
|
||||
) -> list[ActivityRead]:
|
||||
statement = select(ChapterActivity).where(
|
||||
ChapterActivity.chapter_id == coursechapter_id
|
||||
# Get activities that are published and belong to the chapter
|
||||
statement = (
|
||||
select(Activity)
|
||||
.join(ChapterActivity)
|
||||
.where(
|
||||
ChapterActivity.chapter_id == coursechapter_id,
|
||||
Activity.published == True
|
||||
)
|
||||
)
|
||||
activities = db_session.exec(statement).all()
|
||||
|
||||
if not activities:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="No activities found",
|
||||
detail="No published activities found",
|
||||
)
|
||||
|
||||
# RBAC check
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from typing import Literal
|
||||
import json
|
||||
from src.db.courses.courses import Course
|
||||
from src.db.organizations import Organization
|
||||
|
||||
|
|
@ -31,6 +32,7 @@ async def create_video_activity(
|
|||
current_user: PublicUser,
|
||||
db_session: Session,
|
||||
video_file: UploadFile | None = None,
|
||||
details: str = "{}",
|
||||
):
|
||||
# RBAC check
|
||||
await rbac_check(request, "activity_x", current_user, "create", db_session)
|
||||
|
|
@ -39,6 +41,9 @@ async def create_video_activity(
|
|||
statement = select(Chapter).where(Chapter.id == chapter_id)
|
||||
chapter = db_session.exec(statement).first()
|
||||
|
||||
# convert details to dict
|
||||
details = json.loads(details)
|
||||
|
||||
if not chapter:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
|
|
@ -99,6 +104,7 @@ async def create_video_activity(
|
|||
"filename": "video." + video_format,
|
||||
"activity_uuid": activity_uuid,
|
||||
},
|
||||
details=details,
|
||||
version=1,
|
||||
creation_date=str(datetime.now()),
|
||||
update_date=str(datetime.now()),
|
||||
|
|
@ -144,6 +150,7 @@ class ExternalVideo(BaseModel):
|
|||
uri: str
|
||||
type: Literal["youtube", "vimeo"]
|
||||
chapter_id: str
|
||||
details: str = "{}"
|
||||
|
||||
|
||||
class ExternalVideoInDB(BaseModel):
|
||||
|
|
@ -181,6 +188,9 @@ async def create_external_video_activity(
|
|||
# generate activity_uuid
|
||||
activity_uuid = str(f"activity_{uuid4()}")
|
||||
|
||||
# convert details to dict
|
||||
details = json.loads(data.details)
|
||||
|
||||
activity_object = Activity(
|
||||
name=data.name,
|
||||
activity_type=ActivityTypeEnum.TYPE_VIDEO,
|
||||
|
|
@ -194,6 +204,7 @@ async def create_external_video_activity(
|
|||
"type": data.type,
|
||||
"activity_uuid": activity_uuid,
|
||||
},
|
||||
details=details,
|
||||
version=1,
|
||||
creation_date=str(datetime.now()),
|
||||
update_date=str(datetime.now()),
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ async def get_course_chapters(
|
|||
course_id: int,
|
||||
db_session: Session,
|
||||
current_user: PublicUser | AnonymousUser,
|
||||
with_unpublished_activities: bool,
|
||||
page: int = 1,
|
||||
limit: int = 10,
|
||||
) -> List[ChapterRead]:
|
||||
|
|
@ -249,7 +250,7 @@ async def get_course_chapters(
|
|||
for chapter_activity in chapter_activities:
|
||||
statement = (
|
||||
select(Activity)
|
||||
.where(Activity.id == chapter_activity.activity_id)
|
||||
.where(Activity.id == chapter_activity.activity_id, with_unpublished_activities or Activity.published == True)
|
||||
.distinct(Activity.id)
|
||||
)
|
||||
activity = db_session.exec(statement).first()
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ async def get_course_by_id(
|
|||
async def get_course_meta(
|
||||
request: Request,
|
||||
course_uuid: str,
|
||||
with_unpublished_activities: bool,
|
||||
current_user: PublicUser | AnonymousUser,
|
||||
db_session: Session,
|
||||
) -> FullCourseReadWithTrail:
|
||||
|
|
@ -165,7 +166,7 @@ async def get_course_meta(
|
|||
# Ensure course.id is not None
|
||||
if course.id is None:
|
||||
return []
|
||||
return await get_course_chapters(request, course.id, db_session, current_user)
|
||||
return await get_course_chapters(request, course.id, db_session, current_user, with_unpublished_activities)
|
||||
|
||||
# Task 3: Get user trail (only for authenticated users)
|
||||
async def get_trail():
|
||||
|
|
|
|||
|
|
@ -282,6 +282,79 @@ async def add_activity_to_trail(
|
|||
|
||||
return trail_read
|
||||
|
||||
async def remove_activity_from_trail(
|
||||
request: Request,
|
||||
user: PublicUser,
|
||||
activity_uuid: str,
|
||||
db_session: Session,
|
||||
) -> TrailRead:
|
||||
# Look for the activity
|
||||
statement = select(Activity).where(Activity.activity_uuid == activity_uuid)
|
||||
activity = db_session.exec(statement).first()
|
||||
|
||||
if not activity:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Activity not found"
|
||||
)
|
||||
|
||||
statement = select(Course).where(Course.id == activity.course_id)
|
||||
course = db_session.exec(statement).first()
|
||||
|
||||
if not course:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Course not found"
|
||||
)
|
||||
|
||||
statement = select(Trail).where(
|
||||
Trail.org_id == course.org_id, Trail.user_id == user.id
|
||||
)
|
||||
trail = db_session.exec(statement).first()
|
||||
|
||||
if not trail:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Trail not found"
|
||||
)
|
||||
|
||||
# Delete the trail step for this activity
|
||||
statement = select(TrailStep).where(
|
||||
TrailStep.activity_id == activity.id,
|
||||
TrailStep.user_id == user.id,
|
||||
TrailStep.trail_id == trail.id
|
||||
)
|
||||
trail_step = db_session.exec(statement).first()
|
||||
|
||||
if trail_step:
|
||||
db_session.delete(trail_step)
|
||||
db_session.commit()
|
||||
|
||||
# Get updated trail data
|
||||
statement = select(TrailRun).where(TrailRun.trail_id == trail.id, TrailRun.user_id == user.id)
|
||||
trail_runs = db_session.exec(statement).all()
|
||||
|
||||
trail_runs = [
|
||||
TrailRunRead(**trail_run.__dict__, course={}, steps=[], course_total_steps=0)
|
||||
for trail_run in trail_runs
|
||||
]
|
||||
|
||||
for trail_run in trail_runs:
|
||||
statement = select(TrailStep).where(TrailStep.trailrun_id == trail_run.id, TrailStep.user_id == user.id)
|
||||
trail_steps = db_session.exec(statement).all()
|
||||
|
||||
trail_steps = [TrailStep(**trail_step.__dict__) for trail_step in trail_steps]
|
||||
trail_run.steps = trail_steps
|
||||
|
||||
for trail_step in trail_steps:
|
||||
statement = select(Course).where(Course.id == trail_step.course_id)
|
||||
course = db_session.exec(statement).first()
|
||||
trail_step.data = dict(course=course)
|
||||
|
||||
trail_read = TrailRead(
|
||||
**trail.model_dump(),
|
||||
runs=trail_runs,
|
||||
)
|
||||
|
||||
return trail_read
|
||||
|
||||
|
||||
async def add_course_to_trail(
|
||||
request: Request,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { getAPIUrl, getUriWithOrg } from '@services/config/config'
|
|||
import Canva from '@components/Objects/Activities/DynamicCanva/DynamicCanva'
|
||||
import VideoActivity from '@components/Objects/Activities/Video/Video'
|
||||
import { BookOpenCheck, Check, CheckCircle, ChevronDown, ChevronLeft, ChevronRight, FileText, Folder, List, Menu, MoreVertical, UserRoundPen, Video, Layers, ListFilter, ListTree, X, Edit2 } from 'lucide-react'
|
||||
import { markActivityAsComplete } from '@services/courses/activity'
|
||||
import { markActivityAsComplete, unmarkActivityAsComplete } from '@services/courses/activity'
|
||||
import DocumentPdfActivity from '@components/Objects/Activities/DocumentPdf/DocumentPdf'
|
||||
import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators'
|
||||
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
|
||||
|
|
@ -28,6 +28,11 @@ import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationMo
|
|||
import { useMediaQuery } from 'usehooks-ts'
|
||||
import PaidCourseActivityDisclaimer from '@components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer'
|
||||
import { useContributorStatus } from '../../../../../../../../hooks/useContributorStatus'
|
||||
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
|
||||
import ActivityNavigation from '@components/Pages/Activity/ActivityNavigation'
|
||||
import ActivityChapterDropdown from '@components/Pages/Activity/ActivityChapterDropdown'
|
||||
import FixedActivitySecondaryBar from '@components/Pages/Activity/FixedActivitySecondaryBar'
|
||||
import CourseEndView from '@components/Pages/Activity/CourseEndView'
|
||||
|
||||
interface ActivityClientProps {
|
||||
activityid: string
|
||||
|
|
@ -91,7 +96,17 @@ function ActivityClient(props: ActivityClientProps) {
|
|||
<CourseProvider courseuuid={course?.course_uuid}>
|
||||
<AIChatBotProvider>
|
||||
<GeneralWrapperStyled>
|
||||
<div className="space-y-4 pt-4">
|
||||
{activityid === 'end' ? (
|
||||
<CourseEndView
|
||||
courseName={course.name}
|
||||
orgslug={orgslug}
|
||||
courseUuid={course.course_uuid}
|
||||
thumbnailImage={course.thumbnail_image}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4 pt-0">
|
||||
<div className="pt-2">
|
||||
<div className="space-y-4 pb-4 activity-info-section">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex space-x-6">
|
||||
<div className="flex">
|
||||
|
|
@ -117,6 +132,7 @@ function ActivityClient(props: ActivityClientProps) {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActivityIndicators
|
||||
course_uuid={courseuuid}
|
||||
current_activity={activityid}
|
||||
|
|
@ -143,7 +159,7 @@ function ActivityClient(props: ActivityClientProps) {
|
|||
<div className="flex space-x-2 items-center">
|
||||
{activity && activity.published == true && activity.content.paid_access != false && (
|
||||
<AuthenticatedClientElement checkMethod="authentication">
|
||||
{activity.activity_type != 'TYPE_ASSIGNMENT' &&
|
||||
{activity.activity_type != 'TYPE_ASSIGNMENT' && (
|
||||
<>
|
||||
<AIActivityAsk activity={activity} />
|
||||
{contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && (
|
||||
|
|
@ -163,8 +179,8 @@ function ActivityClient(props: ActivityClientProps) {
|
|||
orgslug={orgslug}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
{activity.activity_type == 'TYPE_ASSIGNMENT' &&
|
||||
)}
|
||||
{activity.activity_type == 'TYPE_ASSIGNMENT' && (
|
||||
<>
|
||||
<MoreVertical size={17} className="text-gray-300 " />
|
||||
<AssignmentSubmissionProvider assignment_uuid={assignment?.assignment_uuid}>
|
||||
|
|
@ -177,11 +193,12 @@ function ActivityClient(props: ActivityClientProps) {
|
|||
/>
|
||||
</AssignmentSubmissionProvider>
|
||||
</>
|
||||
}
|
||||
)}
|
||||
</AuthenticatedClientElement>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{activity && activity.published == false && (
|
||||
<div className="p-7 drop-shadow-xs rounded-lg bg-gray-800">
|
||||
<div className="text-white">
|
||||
|
|
@ -233,17 +250,21 @@ function ActivityClient(props: ActivityClientProps) {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Activity Navigation */}
|
||||
|
||||
{/* Fixed Activity Secondary Bar */}
|
||||
{activity && activity.published == true && activity.content.paid_access != false && (
|
||||
<ActivityNavigation
|
||||
<FixedActivitySecondaryBar
|
||||
course={course}
|
||||
currentActivityId={activity.activity_uuid ? activity.activity_uuid.replace('activity_', '') : activityid.replace('activity_', '')}
|
||||
currentActivityId={activityid}
|
||||
orgslug={orgslug}
|
||||
activity={activity}
|
||||
/>
|
||||
)}
|
||||
|
||||
{<div style={{ height: '100px' }}></div>}
|
||||
<div style={{ height: '100px' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</GeneralWrapperStyled>
|
||||
</AIChatBotProvider>
|
||||
</CourseProvider>
|
||||
|
|
@ -262,10 +283,73 @@ export function MarkStatus(props: {
|
|||
const isMobile = useMediaQuery('(max-width: 768px)')
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
const areAllActivitiesCompleted = () => {
|
||||
const run = props.course.trail.runs.find(
|
||||
(run: any) => run.course_id == props.course.id
|
||||
);
|
||||
if (!run) return false;
|
||||
|
||||
let totalActivities = 0;
|
||||
let completedActivities = 0;
|
||||
|
||||
// Count all activities and completed activities
|
||||
props.course.chapters.forEach((chapter: any) => {
|
||||
chapter.activities.forEach((activity: any) => {
|
||||
totalActivities++;
|
||||
const isCompleted = run.steps.find(
|
||||
(step: any) => step.activity_id === activity.id && step.complete === true
|
||||
);
|
||||
if (isCompleted) {
|
||||
completedActivities++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('Total activities:', totalActivities);
|
||||
console.log('Completed activities:', completedActivities);
|
||||
console.log('All completed?', completedActivities >= totalActivities - 1);
|
||||
|
||||
// We check for totalActivities - 1 because the current activity completion
|
||||
// hasn't been counted yet (it's in progress)
|
||||
return completedActivities >= totalActivities - 1;
|
||||
};
|
||||
|
||||
async function markActivityAsCompleteFront() {
|
||||
try {
|
||||
// Check if this will be the last activity to complete
|
||||
const willCompleteAll = areAllActivitiesCompleted();
|
||||
console.log('Will complete all?', willCompleteAll);
|
||||
|
||||
setIsLoading(true);
|
||||
const trail = await markActivityAsComplete(
|
||||
await markActivityAsComplete(
|
||||
props.orgslug,
|
||||
props.course.course_uuid,
|
||||
props.activity.activity_uuid,
|
||||
session.data?.tokens?.access_token
|
||||
);
|
||||
|
||||
// Mutate the course data
|
||||
await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`);
|
||||
|
||||
if (willCompleteAll) {
|
||||
console.log('Redirecting to end page...');
|
||||
const cleanCourseUuid = props.course.course_uuid.replace('course_', '');
|
||||
router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/end`);
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking activity as complete:', error);
|
||||
toast.error('Failed to mark activity as complete');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function unmarkActivityAsCompleteFront() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const trail = await unmarkActivityAsComplete(
|
||||
props.orgslug,
|
||||
props.course.course_uuid,
|
||||
props.activity.activity_uuid,
|
||||
|
|
@ -276,7 +360,7 @@ export function MarkStatus(props: {
|
|||
await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error('Failed to mark activity as complete');
|
||||
toast.error('Failed to unmark activity as complete');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -296,13 +380,45 @@ export function MarkStatus(props: {
|
|||
return (
|
||||
<>
|
||||
{isActivityCompleted() ? (
|
||||
<div className="bg-teal-600 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="bg-teal-600 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white">
|
||||
<i>
|
||||
<Check size={17}></Check>
|
||||
</i>{' '}
|
||||
<i className="not-italic text-xs font-bold">Complete</i>
|
||||
</div>
|
||||
<ToolTip
|
||||
content="Unmark as complete"
|
||||
side="top"
|
||||
>
|
||||
<ConfirmationModal
|
||||
confirmationButtonText="Unmark Activity"
|
||||
confirmationMessage="Are you sure you want to unmark this activity as complete? This will affect your course progress."
|
||||
dialogTitle="Unmark activity as complete"
|
||||
dialogTrigger={
|
||||
<div
|
||||
className={`${isLoading ? 'opacity-75 cursor-not-allowed' : ''} bg-red-400 rounded-full p-2 drop-shadow-md flex items-center text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="animate-spin">
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<X size={17} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
functionToExecute={unmarkActivityAsCompleteFront}
|
||||
status="warning"
|
||||
/>
|
||||
</ToolTip>
|
||||
<NextActivityButton course={props.course} currentActivityId={props.activity.id} orgslug={props.orgslug} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`${isLoading ? 'opacity-75 cursor-not-allowed' : ''} bg-gray-800 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out`}
|
||||
onClick={!isLoading ? markActivityAsCompleteFront : undefined}
|
||||
|
|
@ -321,11 +437,64 @@ export function MarkStatus(props: {
|
|||
)}{' '}
|
||||
{!isMobile && <i className="not-italic text-xs font-bold">{isLoading ? 'Marking...' : 'Mark as complete'}</i>}
|
||||
</div>
|
||||
<NextActivityButton course={props.course} currentActivityId={props.activity.id} orgslug={props.orgslug} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NextActivityButton({ course, currentActivityId, orgslug }: { course: any, currentActivityId: string, orgslug: string }) {
|
||||
const router = useRouter();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
const findNextActivity = () => {
|
||||
let allActivities: any[] = [];
|
||||
let currentIndex = -1;
|
||||
|
||||
// Flatten all activities from all chapters
|
||||
course.chapters.forEach((chapter: any) => {
|
||||
chapter.activities.forEach((activity: any) => {
|
||||
const cleanActivityUuid = activity.activity_uuid?.replace('activity_', '');
|
||||
allActivities.push({
|
||||
...activity,
|
||||
cleanUuid: cleanActivityUuid,
|
||||
chapterName: chapter.name
|
||||
});
|
||||
|
||||
// Check if this is the current activity
|
||||
if (activity.id === currentActivityId) {
|
||||
currentIndex = allActivities.length - 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Get next activity
|
||||
return currentIndex < allActivities.length - 1 ? allActivities[currentIndex + 1] : null;
|
||||
};
|
||||
|
||||
const nextActivity = findNextActivity();
|
||||
|
||||
if (!nextActivity) return null;
|
||||
|
||||
const navigateToActivity = () => {
|
||||
const cleanCourseUuid = course.course_uuid?.replace('course_', '');
|
||||
router.push(getUriWithOrg(orgslug, '') + `/course/${cleanCourseUuid}/activity/${nextActivity.cleanUuid}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolTip content={`Next: ${nextActivity.name}`} side="top">
|
||||
<div
|
||||
onClick={navigateToActivity}
|
||||
className="bg-gray-300 rounded-full px-5 nice-shadow flex items-center space-x-2 p-2.5 text-gray-600 hover:cursor-pointer transition delay-150 duration-300 ease-in-out"
|
||||
>
|
||||
{!isMobile && <span className="text-xs font-bold">Next</span>}
|
||||
<ChevronRight size={17} />
|
||||
</div>
|
||||
</ToolTip>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentTools(props: {
|
||||
activity: any
|
||||
activityid: string
|
||||
|
|
@ -442,335 +611,4 @@ function AssignmentTools(props: {
|
|||
return null
|
||||
}
|
||||
|
||||
function ActivityChapterDropdown(props: {
|
||||
course: any
|
||||
currentActivityId: string
|
||||
orgslug: string
|
||||
}): React.ReactNode {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null);
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
// Function to get the appropriate icon for activity type
|
||||
const getActivityTypeIcon = (activityType: string) => {
|
||||
switch (activityType) {
|
||||
case 'TYPE_VIDEO':
|
||||
return <Video size={16} />;
|
||||
case 'TYPE_DOCUMENT':
|
||||
return <FileText size={16} />;
|
||||
case 'TYPE_DYNAMIC':
|
||||
return <Layers size={16} />;
|
||||
case 'TYPE_ASSIGNMENT':
|
||||
return <BookOpenCheck size={16} />;
|
||||
default:
|
||||
return <FileText size={16} />;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to get the appropriate badge color for activity type
|
||||
const getActivityTypeBadgeColor = (activityType: string) => {
|
||||
return 'bg-gray-100 text-gray-600';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
className="flex items-center justify-center bg-white nice-shadow p-2.5 rounded-full cursor-pointer"
|
||||
aria-label="View all activities"
|
||||
title="View all activities"
|
||||
>
|
||||
<ListTree size={18} className="text-gray-700" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className={`absolute z-50 mt-2 ${isMobile ? 'left-0 w-[90vw] sm:w-80' : 'left-0 w-80'} max-h-[70vh] cursor-pointer overflow-y-auto bg-white rounded-lg shadow-xl border border-gray-200 py-2 animate-in fade-in duration-200`}>
|
||||
<div className="px-4 py-2 border-b border-gray-100 flex justify-between items-center">
|
||||
<h3 className="font-bold text-gray-800">Course Content</h3>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-500 hover:text-gray-700 p-1 rounded-full hover:bg-gray-100 cursor-pointer"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="py-1">
|
||||
{props.course.chapters.map((chapter: any) => (
|
||||
<div key={chapter.id} className="mb-2">
|
||||
<div className="px-4 py-2 font-medium text-gray-600 bg-gray-50 border-y border-gray-100 flex items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Folder size={16} className="text-gray-400" />
|
||||
<span>{chapter.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{chapter.activities.map((activity: any) => {
|
||||
// Remove any prefixes from UUIDs
|
||||
const cleanActivityUuid = activity.activity_uuid?.replace('activity_', '');
|
||||
const cleanCourseUuid = props.course.course_uuid?.replace('course_', '');
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={activity.id}
|
||||
href={getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/${cleanActivityUuid}`}
|
||||
prefetch={false}
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<div
|
||||
className={`px-4 py-2.5 hover:bg-gray-100 transition-colors flex items-center ${
|
||||
cleanActivityUuid === props.currentActivityId.replace('activity_', '') ? 'bg-gray-50 border-l-2 border-gray-300 pl-3 font-medium' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
<span className="text-gray-400">
|
||||
{getActivityTypeIcon(activity.activity_type)}
|
||||
</span>
|
||||
<div className="text-sm">
|
||||
{activity.name}
|
||||
</div>
|
||||
</div>
|
||||
{props.course.trail?.runs?.find(
|
||||
(run: any) => run.course_id === props.course.id
|
||||
)?.steps?.find(
|
||||
(step: any) => (step.activity_id === activity.id || step.activity_id === activity.activity_uuid) && step.complete === true
|
||||
) && (
|
||||
<span className="ml-2 text-gray-400 shrink-0">
|
||||
<Check size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityNavigation(props: {
|
||||
course: any
|
||||
currentActivityId: string
|
||||
orgslug: string
|
||||
}): React.ReactNode {
|
||||
const router = useRouter();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const [isBottomNavVisible, setIsBottomNavVisible] = React.useState(true);
|
||||
const bottomNavRef = React.useRef<HTMLDivElement>(null);
|
||||
const [navWidth, setNavWidth] = React.useState<number | null>(null);
|
||||
|
||||
// Function to find the current activity's position in the course
|
||||
const findActivityPosition = () => {
|
||||
let allActivities: any[] = [];
|
||||
let currentIndex = -1;
|
||||
|
||||
// Flatten all activities from all chapters
|
||||
props.course.chapters.forEach((chapter: any) => {
|
||||
chapter.activities.forEach((activity: any) => {
|
||||
const cleanActivityUuid = activity.activity_uuid?.replace('activity_', '');
|
||||
allActivities.push({
|
||||
...activity,
|
||||
cleanUuid: cleanActivityUuid,
|
||||
chapterName: chapter.name
|
||||
});
|
||||
|
||||
// Check if this is the current activity
|
||||
if (cleanActivityUuid === props.currentActivityId.replace('activity_', '')) {
|
||||
currentIndex = allActivities.length - 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { allActivities, currentIndex };
|
||||
};
|
||||
|
||||
const { allActivities, currentIndex } = findActivityPosition();
|
||||
|
||||
// Get previous and next activities
|
||||
const prevActivity = currentIndex > 0 ? allActivities[currentIndex - 1] : null;
|
||||
const nextActivity = currentIndex < allActivities.length - 1 ? allActivities[currentIndex + 1] : null;
|
||||
|
||||
// Navigate to an activity
|
||||
const navigateToActivity = (activity: any) => {
|
||||
if (!activity) return;
|
||||
|
||||
const cleanCourseUuid = props.course.course_uuid?.replace('course_', '');
|
||||
router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/${activity.cleanUuid}`);
|
||||
};
|
||||
|
||||
// Set up intersection observer to detect when bottom nav is out of viewport
|
||||
// and measure the width of the bottom navigation
|
||||
React.useEffect(() => {
|
||||
if (!bottomNavRef.current) return;
|
||||
|
||||
// Update width when component mounts and on window resize
|
||||
const updateWidth = () => {
|
||||
if (bottomNavRef.current) {
|
||||
setNavWidth(bottomNavRef.current.offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial width measurement
|
||||
updateWidth();
|
||||
|
||||
// Set up resize listener
|
||||
window.addEventListener('resize', updateWidth);
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
setIsBottomNavVisible(entry.isIntersecting);
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
);
|
||||
|
||||
observer.observe(bottomNavRef.current);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateWidth);
|
||||
if (bottomNavRef.current) {
|
||||
observer.unobserve(bottomNavRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Navigation buttons component - reused for both top and bottom
|
||||
const NavigationButtons = ({ isFloating = false }) => (
|
||||
<div className={`${isFloating ? 'flex justify-between' : 'grid grid-cols-3'} items-center w-full`}>
|
||||
{isFloating ? (
|
||||
// Floating navigation - original flex layout
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigateToActivity(prevActivity)}
|
||||
className={`flex items-center space-x-1.5 p-2 rounded-md transition-all duration-200 cursor-pointer ${
|
||||
prevActivity
|
||||
? 'text-gray-700'
|
||||
: 'opacity-50 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!prevActivity}
|
||||
title={prevActivity ? `Previous: ${prevActivity.name}` : 'No previous activity'}
|
||||
>
|
||||
<ChevronLeft size={20} className="text-gray-800 shrink-0" />
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-xs text-gray-500">Previous</span>
|
||||
<span className="text-sm capitalize font-semibold text-left">
|
||||
{prevActivity ? prevActivity.name : 'No previous activity'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigateToActivity(nextActivity)}
|
||||
className={`flex items-center space-x-1.5 p-2 rounded-md transition-all duration-200 cursor-pointer ${
|
||||
nextActivity
|
||||
? 'text-gray-700'
|
||||
: 'opacity-50 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!nextActivity}
|
||||
title={nextActivity ? `Next: ${nextActivity.name}` : 'No next activity'}
|
||||
>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs text-gray-500">Next</span>
|
||||
<span className="text-sm capitalize font-semibold text-right">
|
||||
{nextActivity ? nextActivity.name : 'No next activity'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight size={20} className="text-gray-800 shrink-0" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
// Regular navigation - grid layout with centered counter
|
||||
<>
|
||||
<div className="justify-self-start">
|
||||
<button
|
||||
onClick={() => navigateToActivity(prevActivity)}
|
||||
className={`flex items-center space-x-1.5 px-3.5 py-2 rounded-md transition-all duration-200 cursor-pointer ${
|
||||
prevActivity
|
||||
? 'bg-white nice-shadow text-gray-700'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!prevActivity}
|
||||
title={prevActivity ? `Previous: ${prevActivity.name}` : 'No previous activity'}
|
||||
>
|
||||
<ChevronLeft size={16} className="shrink-0" />
|
||||
<div className="flex flex-col items-start">
|
||||
<span className="text-xs text-gray-500">Previous</span>
|
||||
<span className="text-sm capitalize font-semibold text-left">
|
||||
{prevActivity ? prevActivity.name : 'No previous activity'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500 justify-self-center">
|
||||
{currentIndex + 1} of {allActivities.length}
|
||||
</div>
|
||||
|
||||
<div className="justify-self-end">
|
||||
<button
|
||||
onClick={() => navigateToActivity(nextActivity)}
|
||||
className={`flex items-center space-x-1.5 px-3.5 py-2 rounded-md transition-all duration-200 cursor-pointer ${
|
||||
nextActivity
|
||||
? 'bg-white nice-shadow text-gray-700'
|
||||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!nextActivity}
|
||||
title={nextActivity ? `Next: ${nextActivity.name}` : 'No next activity'}
|
||||
>
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs text-gray-500">Next</span>
|
||||
<span className="text-sm capitalize font-semibold text-right">
|
||||
{nextActivity ? nextActivity.name : 'No next activity'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bottom navigation (in-place) */}
|
||||
<div ref={bottomNavRef} className="mt-6 mb-2 w-full">
|
||||
<NavigationButtons isFloating={false} />
|
||||
</div>
|
||||
|
||||
{/* Floating bottom navigation - shown when bottom nav is not visible */}
|
||||
{!isBottomNavVisible && (
|
||||
<div className="fixed bottom-8 left-1/2 transform -translate-x-1/2 z-50 w-[85%] sm:w-auto sm:min-w-[350px] max-w-lg transition-all duration-300 ease-in-out">
|
||||
<div
|
||||
className="bg-white/90 backdrop-blur-xl rounded-full py-1.5 px-2.5 shadow-xs animate-in fade-in slide-in-from-bottom duration-300"
|
||||
>
|
||||
<NavigationButtons isFloating={true} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActivityClient
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
|
|||
|
||||
return (
|
||||
<div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto_1fr]">
|
||||
<CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)}>
|
||||
<CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)} withUnpublishedActivities={true}>
|
||||
<div className="pl-10 pr-10 text-sm tracking-tight bg-[#fcfbfc] z-10 nice-shadow">
|
||||
<CourseOverviewTop params={params} />
|
||||
<div className="flex space-x-3 font-black text-sm">
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import { useLHSession } from '@components/Contexts/LHSessionContext'
|
|||
export const CourseContext = createContext(null)
|
||||
export const CourseDispatchContext = createContext(null)
|
||||
|
||||
export function CourseProvider({ children, courseuuid }: any) {
|
||||
export function CourseProvider({ children, courseuuid, withUnpublishedActivities = false }: any) {
|
||||
const session = useLHSession() as any;
|
||||
const access_token = session?.data?.tokens?.access_token;
|
||||
|
||||
const { data: courseStructureData, error } = useSWR(`${getAPIUrl()}courses/${courseuuid}/meta`,
|
||||
const { data: courseStructureData, error } = useSWR(`${getAPIUrl()}courses/${courseuuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`,
|
||||
url => swrFetcher(url, access_token)
|
||||
);
|
||||
|
||||
|
|
@ -22,7 +22,8 @@ export function CourseProvider({ children, courseuuid }: any) {
|
|||
},
|
||||
courseOrder: {},
|
||||
isSaved: true,
|
||||
isLoading: true
|
||||
isLoading: true,
|
||||
withUnpublishedActivities: withUnpublishedActivities
|
||||
};
|
||||
|
||||
const [state, dispatch] = useReducer(courseReducer, initialState) as any;
|
||||
|
|
|
|||
|
|
@ -6,37 +6,43 @@ import {
|
|||
useCourse,
|
||||
useCourseDispatch,
|
||||
} from '@components/Contexts/CourseContext'
|
||||
import { Check, SaveAllIcon, Timer } from 'lucide-react'
|
||||
import { Check, SaveAllIcon, Timer, Loader2 } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { mutate } from 'swr'
|
||||
import { updateCourse } from '@services/courses/courses'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
|
||||
function SaveState(props: { orgslug: string }) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const course = useCourse() as any
|
||||
const session = useLHSession() as any;
|
||||
const router = useRouter()
|
||||
const saved = course ? course.isSaved : true
|
||||
const dispatchCourse = useCourseDispatch() as any
|
||||
const course_structure = course.courseStructure
|
||||
|
||||
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
|
||||
const saveCourseState = async () => {
|
||||
if (saved || isLoading) return
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Course order
|
||||
if (saved) return
|
||||
await changeOrderBackend()
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
// Course metadata
|
||||
await changeMetadataBackend()
|
||||
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)
|
||||
dispatchCourse({ type: 'setIsSaved' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Course Order
|
||||
const changeOrderBackend = async () => {
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
await updateCourseOrderStructure(
|
||||
course.courseStructure.course_uuid,
|
||||
course.courseOrder,
|
||||
|
|
@ -49,7 +55,7 @@ function SaveState(props: { orgslug: string }) {
|
|||
|
||||
// Course metadata
|
||||
const changeMetadataBackend = async () => {
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
await updateCourse(
|
||||
course.courseStructure.course_uuid,
|
||||
course.courseStructure,
|
||||
|
|
@ -117,12 +123,25 @@ function SaveState(props: { orgslug: string }) {
|
|||
`px-4 py-2 rounded-lg drop-shadow-md cursor-pointer flex space-x-2 items-center font-bold antialiased transition-all ease-linear ` +
|
||||
(saved
|
||||
? 'bg-gray-600 text-white'
|
||||
: 'bg-black text-white border hover:bg-gray-900 ')
|
||||
: 'bg-black text-white border hover:bg-gray-900 ') +
|
||||
(isLoading ? 'opacity-50 cursor-not-allowed' : '')
|
||||
}
|
||||
onClick={saveCourseState}
|
||||
>
|
||||
{saved ? <Check size={20} /> : <SaveAllIcon size={20} />}
|
||||
{saved ? <div className="">Saved</div> : <div className="">Save</div>}
|
||||
{isLoading ? (
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
) : saved ? (
|
||||
<Check size={20} />
|
||||
) : (
|
||||
<SaveAllIcon size={20} />
|
||||
)}
|
||||
{isLoading ? (
|
||||
<div className="">Saving...</div>
|
||||
) : saved ? (
|
||||
<div className="">Saved</div>
|
||||
) : (
|
||||
<div className="">Save</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,66 +5,128 @@ import { updateCourseThumbnail } from '@services/courses/courses'
|
|||
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
||||
import { ArrowBigUpDash, UploadCloud, Image as ImageIcon } from 'lucide-react'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { mutate } from 'swr'
|
||||
import UnsplashImagePicker from './UnsplashImagePicker'
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const VALID_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/png'] as const;
|
||||
|
||||
type ValidMimeType = typeof VALID_MIME_TYPES[number];
|
||||
|
||||
function ThumbnailUpdate() {
|
||||
const course = useCourse() as any
|
||||
const session = useLHSession() as any;
|
||||
const org = useOrg() as any
|
||||
const [localThumbnail, setLocalThumbnail] = React.useState(null) as any
|
||||
const [isLoading, setIsLoading] = React.useState(false) as any
|
||||
const [error, setError] = React.useState('') as any
|
||||
const [localThumbnail, setLocalThumbnail] = useState<{ file: File; url: string } | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [showUnsplashPicker, setShowUnsplashPicker] = useState(false)
|
||||
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
|
||||
|
||||
const handleFileChange = async (event: any) => {
|
||||
const file = event.target.files[0]
|
||||
setLocalThumbnail(file)
|
||||
await updateThumbnail(file)
|
||||
// Cleanup blob URLs when component unmounts or when thumbnail changes
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (localThumbnail?.url) {
|
||||
URL.revokeObjectURL(localThumbnail.url);
|
||||
}
|
||||
};
|
||||
}, [localThumbnail]);
|
||||
|
||||
const validateFile = (file: File): boolean => {
|
||||
if (!VALID_MIME_TYPES.includes(file.type as ValidMimeType)) {
|
||||
setError('Please upload only PNG or JPG/JPEG images');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setError('File size should be less than 5MB');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!validateFile(file)) {
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
setLocalThumbnail({ file, url: blobUrl });
|
||||
await updateThumbnail(file);
|
||||
}
|
||||
|
||||
const handleUnsplashSelect = async (imageUrl: string) => {
|
||||
setIsLoading(true)
|
||||
const response = await fetch(imageUrl)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], 'unsplash_image.jpg', { type: 'image/jpeg' })
|
||||
setLocalThumbnail(file)
|
||||
await updateThumbnail(file)
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(imageUrl);
|
||||
const blob = await response.blob();
|
||||
|
||||
if (!VALID_MIME_TYPES.includes(blob.type as ValidMimeType)) {
|
||||
throw new Error('Invalid image format from Unsplash');
|
||||
}
|
||||
|
||||
const file = new File([blob], `unsplash_${Date.now()}.jpg`, { type: blob.type });
|
||||
|
||||
if (!validateFile(file)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
setLocalThumbnail({ file, url: blobUrl });
|
||||
await updateThumbnail(file);
|
||||
} catch (err) {
|
||||
setError('Failed to process Unsplash image');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const updateThumbnail = async (file: File) => {
|
||||
setIsLoading(true)
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await updateCourseThumbnail(
|
||||
course.courseStructure.course_uuid,
|
||||
file,
|
||||
session.data?.tokens?.access_token
|
||||
)
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
|
||||
// wait for 1 second to show loading animation
|
||||
await new Promise((r) => setTimeout(r, 1500))
|
||||
);
|
||||
|
||||
await mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`);
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
if (res.success === false) {
|
||||
setError(res.HTTPmessage)
|
||||
setError(res.HTTPmessage);
|
||||
} else {
|
||||
setIsLoading(false)
|
||||
setError('')
|
||||
setError('');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to update thumbnail');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-auto bg-gray-50 rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow-sm">
|
||||
<div className="flex flex-col justify-center items-center h-full">
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<div className="w-auto rounded-xl border border-gray-200 h-[250px] light-shadow bg-gray-50 transition-all duration-200">
|
||||
<div className="flex flex-col justify-center items-center h-full p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="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 className="absolute top-4 flex justify-center bg-red-50 rounded-lg text-red-800 space-x-2 items-center p-3 transition-all">
|
||||
<div className="text-sm font-medium">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
{localThumbnail ? (
|
||||
<img
|
||||
src={URL.createObjectURL(localThumbnail)}
|
||||
className={`${isLoading ? 'animate-pulse' : ''} shadow-sm w-[200px] h-[100px] rounded-md`}
|
||||
src={localThumbnail.url}
|
||||
className={`${
|
||||
isLoading ? 'animate-pulse' : ''
|
||||
} shadow-sm w-[280px] h-[140px] object-cover rounded-lg border border-gray-200`}
|
||||
alt="Course thumbnail"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
|
|
@ -73,43 +135,50 @@ function ThumbnailUpdate() {
|
|||
course.courseStructure.course_uuid,
|
||||
course.courseStructure.thumbnail_image
|
||||
) : '/empty_thumbnail.png'}`}
|
||||
className="shadow-sm w-[200px] h-[100px] rounded-md bg-gray-200"
|
||||
className="shadow-sm w-[280px] h-[140px] object-cover rounded-lg border border-gray-200 bg-gray-50"
|
||||
alt="Course thumbnail"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="font-bold animate-pulse antialiased items-center bg-green-200 text-gray text-sm rounded-md px-4 py-2 mt-4 flex">
|
||||
<ArrowBigUpDash size={16} className="mr-2" />
|
||||
<span>Uploading</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center items-center space-x-2">
|
||||
|
||||
{!isLoading && (
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="file"
|
||||
id="fileInput"
|
||||
style={{ display: 'none' }}
|
||||
className="hidden"
|
||||
accept=".jpg,.jpeg,.png"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<button
|
||||
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex"
|
||||
className="bg-gray-50 text-gray-800 px-4 py-2 rounded-md text-sm font-medium flex items-center hover:bg-gray-100 transition-colors duration-200 border border-gray-200"
|
||||
onClick={() => document.getElementById('fileInput')?.click()}
|
||||
>
|
||||
<UploadCloud size={16} className="mr-2" />
|
||||
<span>Upload Image</span>
|
||||
Upload
|
||||
</button>
|
||||
<button
|
||||
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex"
|
||||
className="bg-gray-50 text-gray-800 px-4 py-2 rounded-md text-sm font-medium flex items-center hover:bg-gray-100 transition-colors duration-200 border border-gray-200"
|
||||
onClick={() => setShowUnsplashPicker(true)}
|
||||
>
|
||||
<ImageIcon size={16} className="mr-2" />
|
||||
<span>Choose from Gallery</span>
|
||||
Gallery
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="font-medium text-sm text-green-800 bg-green-50 rounded-full px-4 py-2 flex items-center">
|
||||
<ArrowBigUpDash size={16} className="mr-2 animate-bounce" />
|
||||
Uploading...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500">Supported formats: PNG, JPG/JPEG</p>
|
||||
</div>
|
||||
|
||||
{showUnsplashPicker && (
|
||||
<UnsplashImagePicker
|
||||
onSelect={handleUnsplashSelect}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ function NewActivityButton(props: NewActivityButtonProps) {
|
|||
const course = useCourse() as any
|
||||
const session = useLHSession() as any;
|
||||
const access_token = session?.data?.tokens?.access_token;
|
||||
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
|
||||
|
||||
const openNewActivityModal = async (chapterId: any) => {
|
||||
setNewActivityModal(true)
|
||||
|
|
@ -44,7 +45,7 @@ function NewActivityButton(props: NewActivityButtonProps) {
|
|||
)
|
||||
const toast_loading = toast.loading('Creating activity...')
|
||||
await createActivity(activity, props.chapterId, org.org_id, access_token)
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
toast.dismiss(toast_loading)
|
||||
toast.success('Activity created successfully')
|
||||
setNewActivityModal(false)
|
||||
|
|
@ -61,7 +62,7 @@ function NewActivityButton(props: NewActivityButtonProps) {
|
|||
) => {
|
||||
toast.loading('Uploading file and creating activity...')
|
||||
await createFileActivity(file, type, activity, chapterId, access_token)
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
setNewActivityModal(false)
|
||||
toast.dismiss()
|
||||
toast.success('File uploaded successfully')
|
||||
|
|
@ -82,7 +83,7 @@ function NewActivityButton(props: NewActivityButtonProps) {
|
|||
activity,
|
||||
props.chapterId, access_token
|
||||
)
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
setNewActivityModal(false)
|
||||
toast.dismiss(toast_loading)
|
||||
toast.success('Activity created successfully')
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ function ActivityElement(props: ActivitiyElementProps) {
|
|||
const [isUpdatingName, setIsUpdatingName] = React.useState<boolean>(false)
|
||||
const activityUUID = props.activity.activity_uuid
|
||||
const isMobile = useMediaQuery('(max-width: 767px)')
|
||||
const course = useCourse() as any;
|
||||
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
|
||||
|
||||
async function deleteActivityUI() {
|
||||
const toast_loading = toast.loading('Deleting activity...')
|
||||
|
|
@ -65,7 +67,7 @@ function ActivityElement(props: ActivitiyElementProps) {
|
|||
}
|
||||
|
||||
await deleteActivity(props.activity.activity_uuid, access_token)
|
||||
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
|
||||
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
await revalidateTags(['courses'], props.orgslug)
|
||||
toast.dismiss(toast_loading)
|
||||
toast.success('Activity deleted successfully')
|
||||
|
|
@ -82,7 +84,7 @@ function ActivityElement(props: ActivitiyElementProps) {
|
|||
props.activity.activity_uuid,
|
||||
access_token
|
||||
)
|
||||
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
|
||||
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
toast.dismiss(toast_loading)
|
||||
toast.success('The activity has been updated successfully')
|
||||
await revalidateTags(['courses'], props.orgslug)
|
||||
|
|
@ -103,7 +105,7 @@ function ActivityElement(props: ActivitiyElementProps) {
|
|||
|
||||
try {
|
||||
await updateActivity(modifiedActivityCopy, activityUUID, access_token)
|
||||
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
|
||||
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
await revalidateTags(['courses'], props.orgslug)
|
||||
toast.success('Activity name updated successfully')
|
||||
router.refresh()
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { useRouter } from 'next/navigation'
|
|||
import { getAPIUrl } from '@services/config/config'
|
||||
import { mutate } from 'swr'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { useCourse } from '@components/Contexts/CourseContext'
|
||||
|
||||
type ChapterElementProps = {
|
||||
chapter: any
|
||||
|
|
@ -41,12 +42,14 @@ function ChapterElement(props: ChapterElementProps) {
|
|||
const [selectedChapter, setSelectedChapter] = React.useState<
|
||||
string | undefined
|
||||
>(undefined)
|
||||
const course = useCourse() as any;
|
||||
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const deleteChapterUI = async () => {
|
||||
await deleteChapter(props.chapter.id, access_token)
|
||||
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
|
||||
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
await revalidateTags(['courses'], props.orgslug)
|
||||
router.refresh()
|
||||
}
|
||||
|
|
@ -57,7 +60,7 @@ function ChapterElement(props: ChapterElementProps) {
|
|||
name: modifiedChapter.chapterName,
|
||||
}
|
||||
await updateChapter(chapterId, modifiedChapterCopy, access_token)
|
||||
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
|
||||
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
await revalidateTags(['courses'], props.orgslug)
|
||||
router.refresh()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ const EditCourseStructure = (props: EditCourseStructureProps) => {
|
|||
const course = useCourse() as any
|
||||
const course_structure = course ? course.courseStructure : {}
|
||||
const course_uuid = course ? course.courseStructure.course_uuid : ''
|
||||
|
||||
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
|
||||
// New Chapter creation
|
||||
const [newChapterModal, setNewChapterModal] = useState(false)
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ const EditCourseStructure = (props: EditCourseStructureProps) => {
|
|||
// Submit new chapter
|
||||
const submitChapter = async (chapter: any) => {
|
||||
await createChapter(chapter,access_token)
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
|
||||
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
await revalidateTags(['courses'], props.orgslug)
|
||||
router.refresh()
|
||||
setNewChapterModal(false)
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import TableHeader from '@tiptap/extension-table-header'
|
|||
import TableRow from '@tiptap/extension-table-row'
|
||||
import TableCell from '@tiptap/extension-table-cell'
|
||||
import UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock'
|
||||
import { getLinkExtension } from '@components/Objects/Editor/EditorConf'
|
||||
|
||||
interface Editor {
|
||||
content: string
|
||||
|
|
@ -57,7 +58,18 @@ function Canva(props: Editor) {
|
|||
const editor: any = useEditor({
|
||||
editable: isEditable,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: 'bullet-list',
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: 'ordered-list',
|
||||
},
|
||||
},
|
||||
}),
|
||||
NoTextInput,
|
||||
// Custom Extensions
|
||||
InfoCallout.configure({
|
||||
|
|
@ -112,6 +124,7 @@ function Canva(props: Editor) {
|
|||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
getLinkExtension(),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
|
|
@ -194,10 +207,30 @@ const CanvaWrapper = styled.div`
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
// Link styling
|
||||
a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #1d4ed8;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding: 0 1rem;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,17 +3,71 @@ import YouTube from 'react-youtube'
|
|||
import { getActivityMediaDirectory } from '@services/media/media'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
|
||||
function VideoActivity({ activity, course }: { activity: any; course: any }) {
|
||||
interface VideoDetails {
|
||||
startTime?: number
|
||||
endTime?: number | null
|
||||
autoplay?: boolean
|
||||
muted?: boolean
|
||||
}
|
||||
|
||||
interface VideoActivityProps {
|
||||
activity: {
|
||||
activity_sub_type: string
|
||||
activity_uuid: string
|
||||
content: {
|
||||
filename?: string
|
||||
uri?: string
|
||||
}
|
||||
details?: VideoDetails
|
||||
}
|
||||
course: {
|
||||
course_uuid: string
|
||||
}
|
||||
}
|
||||
|
||||
function VideoActivity({ activity, course }: VideoActivityProps) {
|
||||
const org = useOrg() as any
|
||||
const [videoId, setVideoId] = React.useState('')
|
||||
const videoRef = React.useRef<HTMLVideoElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activity && activity.content && activity.content.uri) {
|
||||
var getYouTubeID = require('get-youtube-id');
|
||||
if (activity?.content?.uri) {
|
||||
var getYouTubeID = require('get-youtube-id')
|
||||
setVideoId(getYouTubeID(activity.content.uri))
|
||||
}
|
||||
}, [activity, org])
|
||||
|
||||
const getVideoSrc = () => {
|
||||
if (!activity.content?.filename) return ''
|
||||
return getActivityMediaDirectory(
|
||||
org?.org_uuid,
|
||||
course?.course_uuid,
|
||||
activity.activity_uuid,
|
||||
activity.content.filename,
|
||||
'video'
|
||||
)
|
||||
}
|
||||
|
||||
// Handle native video time update
|
||||
const handleTimeUpdate = () => {
|
||||
const video = videoRef.current
|
||||
if (video && activity.details?.endTime) {
|
||||
if (video.currentTime >= activity.details.endTime) {
|
||||
video.pause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle native video load
|
||||
const handleVideoLoad = () => {
|
||||
const video = videoRef.current
|
||||
if (video && activity.details) {
|
||||
video.currentTime = activity.details.startTime || 0
|
||||
video.autoplay = activity.details.autoplay || false
|
||||
video.muted = activity.details.muted || false
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-full px-2 sm:px-4">
|
||||
{activity && (
|
||||
|
|
@ -22,15 +76,12 @@ function VideoActivity({ activity, course }: { activity: any; course: any }) {
|
|||
<div className="my-3 md:my-5 w-full">
|
||||
<div className="relative w-full aspect-video rounded-lg overflow-hidden ring-1 ring-gray-300/30 dark:ring-gray-600/30 sm:ring-gray-200/10 sm:dark:ring-gray-700/20 shadow-xs sm:shadow-none">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full h-full object-cover"
|
||||
controls
|
||||
src={getActivityMediaDirectory(
|
||||
org?.org_uuid,
|
||||
course?.course_uuid,
|
||||
activity.activity_uuid,
|
||||
activity.content?.filename,
|
||||
'video'
|
||||
)}
|
||||
src={getVideoSrc()}
|
||||
onLoadedMetadata={handleVideoLoad}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
></video>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -44,7 +95,10 @@ function VideoActivity({ activity, course }: { activity: any; course: any }) {
|
|||
width: '100%',
|
||||
height: '100%',
|
||||
playerVars: {
|
||||
autoplay: 0,
|
||||
autoplay: activity.details?.autoplay ? 1 : 0,
|
||||
mute: activity.details?.muted ? 1 : 0,
|
||||
start: activity.details?.startTime || 0,
|
||||
end: activity.details?.endTime || undefined
|
||||
},
|
||||
}}
|
||||
videoId={videoId}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,8 @@ import TableRow from '@tiptap/extension-table-row'
|
|||
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
|
||||
import Link from 'next/link'
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
||||
|
||||
import { getLinkExtension } from './EditorConf'
|
||||
import { Link as LinkExtension } from '@tiptap/extension-link'
|
||||
|
||||
// Lowlight
|
||||
import { common, createLowlight } from 'lowlight'
|
||||
|
|
@ -95,7 +96,18 @@ function Editor(props: Editor) {
|
|||
const editor: any = useEditor({
|
||||
editable: true,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: 'bullet-list',
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: 'ordered-list',
|
||||
},
|
||||
},
|
||||
}),
|
||||
InfoCallout.configure({
|
||||
editable: true,
|
||||
}),
|
||||
|
|
@ -151,6 +163,7 @@ function Editor(props: Editor) {
|
|||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
getLinkExtension(),
|
||||
],
|
||||
content: props.content,
|
||||
immediatelyRender: false,
|
||||
|
|
@ -459,6 +472,19 @@ export const EditorContentWrapper = styled.div`
|
|||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
// Link styling
|
||||
a {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #1d4ed8;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
padding-bottom: 20px;
|
||||
|
|
@ -564,6 +590,13 @@ export const EditorContentWrapper = styled.div`
|
|||
ol {
|
||||
padding: 0 1rem;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
|
|
|
|||
59
apps/web/components/Objects/Editor/EditorConf.ts
Normal file
59
apps/web/components/Objects/Editor/EditorConf.ts
Normal 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
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
118
apps/web/components/Objects/Editor/Toolbar/LinkInputTooltip.tsx
Normal file
118
apps/web/components/Objects/Editor/Toolbar/LinkInputTooltip.tsx
Normal 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
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
FileText,
|
||||
ImagePlus,
|
||||
Lightbulb,
|
||||
Link2,
|
||||
MousePointerClick,
|
||||
Sigma,
|
||||
Table,
|
||||
|
|
@ -30,30 +31,24 @@ import {
|
|||
Tags,
|
||||
User,
|
||||
Video,
|
||||
List,
|
||||
ListOrdered,
|
||||
} from 'lucide-react'
|
||||
import { SiYoutube } from '@icons-pack/react-simple-icons'
|
||||
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
|
||||
import React from 'react'
|
||||
import LinkInputTooltip from './LinkInputTooltip'
|
||||
|
||||
export const ToolbarButtons = ({ editor, props }: any) => {
|
||||
const [showTableMenu, setShowTableMenu] = React.useState(false)
|
||||
const [showListMenu, setShowListMenu] = React.useState(false)
|
||||
const [showLinkInput, setShowLinkInput] = React.useState(false)
|
||||
const linkButtonRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
if (!editor) {
|
||||
return null
|
||||
}
|
||||
|
||||
// YouTube extension
|
||||
const addYoutubeVideo = () => {
|
||||
const url = prompt('Enter YouTube URL')
|
||||
|
||||
if (url) {
|
||||
editor.commands.setYoutubeVideo({
|
||||
src: url,
|
||||
width: 640,
|
||||
height: 480,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const tableOptions = [
|
||||
{
|
||||
|
|
@ -83,6 +78,74 @@ export const ToolbarButtons = ({ editor, props }: any) => {
|
|||
}
|
||||
]
|
||||
|
||||
const listOptions = [
|
||||
{
|
||||
label: 'Bullet List',
|
||||
icon: <List size={15} />,
|
||||
action: () => {
|
||||
if (editor.isActive('bulletList')) {
|
||||
editor.chain().focus().toggleBulletList().run()
|
||||
} else {
|
||||
editor.chain().focus().toggleOrderedList().run()
|
||||
editor.chain().focus().toggleBulletList().run()
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Ordered List',
|
||||
icon: <ListOrdered size={15} />,
|
||||
action: () => {
|
||||
if (editor.isActive('orderedList')) {
|
||||
editor.chain().focus().toggleOrderedList().run()
|
||||
} else {
|
||||
editor.chain().focus().toggleBulletList().run()
|
||||
editor.chain().focus().toggleOrderedList().run()
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const handleLinkClick = () => {
|
||||
// Store the current selection
|
||||
const { from, to } = editor.state.selection
|
||||
|
||||
if (editor.isActive('link')) {
|
||||
const currentLink = editor.getAttributes('link')
|
||||
setShowLinkInput(true)
|
||||
} else {
|
||||
setShowLinkInput(true)
|
||||
}
|
||||
|
||||
// Restore the selection after a small delay to ensure the tooltip is rendered
|
||||
setTimeout(() => {
|
||||
editor.commands.setTextSelection({ from, to })
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const getCurrentLinkUrl = () => {
|
||||
if (editor.isActive('link')) {
|
||||
return editor.getAttributes('link').href
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const handleLinkSave = (url: string) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setLink({
|
||||
href: url,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer'
|
||||
})
|
||||
.run()
|
||||
setShowLinkInput(false)
|
||||
}
|
||||
|
||||
const handleLinkCancel = () => {
|
||||
setShowLinkInput(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolButtonsWrapper>
|
||||
<ToolBtn onClick={() => editor.chain().focus().undo().run()}>
|
||||
|
|
@ -109,12 +172,32 @@ export const ToolbarButtons = ({ editor, props }: any) => {
|
|||
>
|
||||
<StrikethroughIcon />
|
||||
</ToolBtn>
|
||||
<ListMenuWrapper>
|
||||
<ToolBtn
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={editor.isActive('orderedList') ? 'is-active' : ''}
|
||||
onClick={() => setShowListMenu(!showListMenu)}
|
||||
className={showListMenu || editor.isActive('bulletList') || editor.isActive('orderedList') ? 'is-active' : ''}
|
||||
>
|
||||
<ListBulletIcon />
|
||||
<ChevronDownIcon />
|
||||
</ToolBtn>
|
||||
{showListMenu && (
|
||||
<ListDropdown>
|
||||
{listOptions.map((option, index) => (
|
||||
<ListMenuItem
|
||||
key={index}
|
||||
onClick={() => {
|
||||
option.action()
|
||||
setShowListMenu(false)
|
||||
}}
|
||||
className={editor.isActive(option.label === 'Bullet List' ? 'bulletList' : 'orderedList') ? 'is-active' : ''}
|
||||
>
|
||||
<span className="icon">{option.icon}</span>
|
||||
<span className="label">{option.label}</span>
|
||||
</ListMenuItem>
|
||||
))}
|
||||
</ListDropdown>
|
||||
)}
|
||||
</ListMenuWrapper>
|
||||
<ToolSelect
|
||||
value={
|
||||
editor.isActive('heading', { level: 1 }) ? "1" :
|
||||
|
|
@ -185,6 +268,24 @@ export const ToolbarButtons = ({ editor, props }: any) => {
|
|||
<AlertTriangle size={15} />
|
||||
</ToolBtn>
|
||||
</ToolTip>
|
||||
<ToolTip content={'Link'}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<ToolBtn
|
||||
ref={linkButtonRef}
|
||||
onClick={handleLinkClick}
|
||||
className={editor.isActive('link') ? 'is-active' : ''}
|
||||
>
|
||||
<Link2 size={15} />
|
||||
</ToolBtn>
|
||||
{showLinkInput && (
|
||||
<LinkInputTooltip
|
||||
onSave={handleLinkSave}
|
||||
onCancel={handleLinkCancel}
|
||||
currentUrl={getCurrentLinkUrl()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ToolTip>
|
||||
<ToolTip content={'Image'}>
|
||||
<ToolBtn
|
||||
onClick={() =>
|
||||
|
|
@ -428,3 +529,48 @@ const TableMenuItem = styled.div`
|
|||
font-family: 'DM Sans';
|
||||
}
|
||||
`
|
||||
|
||||
const ListMenuWrapper = styled.div`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
`
|
||||
|
||||
const ListDropdown = styled.div`
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background: white;
|
||||
border: 1px solid rgba(217, 217, 217, 0.5);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
min-width: 180px;
|
||||
margin-top: 4px;
|
||||
`
|
||||
|
||||
const ListMenuItem = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: rgba(217, 217, 217, 0.24);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: rgba(176, 176, 176, 0.5);
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-right: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-family: 'DM Sans';
|
||||
}
|
||||
`
|
||||
|
|
@ -4,11 +4,11 @@ import VideoPageActivityImage from 'public//activities_types/video-page-activity
|
|||
import DocumentPdfPageActivityImage from 'public//activities_types/documentpdf-page-activity.png'
|
||||
import AssignmentActivityImage from 'public//activities_types/assignment-page-activity.png'
|
||||
|
||||
import DynamicCanvaModal from './NewActivityModal/DynamicCanva'
|
||||
import VideoModal from './NewActivityModal/Video'
|
||||
import DynamicCanvaModal from './NewActivityModal/DynamicActivityModal'
|
||||
import VideoModal from './NewActivityModal/VideoActivityModal'
|
||||
import Image from 'next/image'
|
||||
import DocumentPdfModal from './NewActivityModal/DocumentPdf'
|
||||
import Assignment from './NewActivityModal/Assignment'
|
||||
import DocumentPdfModal from './NewActivityModal/DocumentActivityModal'
|
||||
import Assignment from './NewActivityModal/AssignmentActivityModal'
|
||||
|
||||
function NewActivityModal({
|
||||
closeModal,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -4,7 +4,7 @@ import AuthenticatedClientElement from '@components/Security/AuthenticatedClient
|
|||
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import { deleteCourseFromBackend } from '@services/courses/courses'
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
||||
import { getCourseThumbnailMediaDirectory, getUserAvatarMediaDirectory } from '@services/media/media'
|
||||
import { revalidateTags } from '@services/utils/ts/requests'
|
||||
import { BookMinus, FilePenLine, Settings2, MoreVertical } from 'lucide-react'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
|
|
@ -12,6 +12,7 @@ import Link from 'next/link'
|
|||
import { useRouter } from 'next/navigation'
|
||||
import React from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import UserAvatar from '@components/Objects/UserAvatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -25,6 +26,19 @@ type Course = {
|
|||
description: string
|
||||
thumbnail_image: string
|
||||
org_id: string
|
||||
update_date: string
|
||||
authors?: Array<{
|
||||
user: {
|
||||
id: string
|
||||
user_uuid: string
|
||||
avatar_image: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
username: string
|
||||
}
|
||||
authorship: 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER'
|
||||
authorship_status: 'ACTIVE' | 'INACTIVE' | 'PENDING'
|
||||
}>
|
||||
}
|
||||
|
||||
type PropsType = {
|
||||
|
|
@ -40,6 +54,11 @@ function CourseThumbnail({ course, orgslug, customLink }: PropsType) {
|
|||
const org = useOrg() as any
|
||||
const session = useLHSession() as any
|
||||
|
||||
const activeAuthors = course.authors?.filter(author => author.authorship_status === 'ACTIVE') || []
|
||||
const displayedAuthors = activeAuthors.slice(0, 3)
|
||||
const hasMoreAuthors = activeAuthors.length > 3
|
||||
const remainingAuthorsCount = activeAuthors.length - 3
|
||||
|
||||
const deleteCourse = async () => {
|
||||
const toastId = toast.loading('Deleting course...')
|
||||
try {
|
||||
|
|
@ -59,7 +78,7 @@ function CourseThumbnail({ course, orgslug, customLink }: PropsType) {
|
|||
: '../empty_thumbnail.png'
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative flex flex-col bg-white rounded-xl nice-shadow overflow-hidden min-w-[280px] w-full max-w-sm shrink-0">
|
||||
<AdminEditOptions
|
||||
course={course}
|
||||
orgSlug={orgslug}
|
||||
|
|
@ -67,13 +86,65 @@ function CourseThumbnail({ course, orgslug, customLink }: PropsType) {
|
|||
/>
|
||||
<Link prefetch href={customLink ? customLink : getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)}>
|
||||
<div
|
||||
className="inset-0 ring-1 ring-inset ring-black/10 rounded-xl shadow-xl w-full aspect-video bg-cover bg-center"
|
||||
className="inset-0 ring-1 ring-inset ring-black/10 rounded-t-xl w-full aspect-video bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${thumbnailImage})` }}
|
||||
/>
|
||||
</Link>
|
||||
<div className='flex flex-col w-full pt-3 space-y-2'>
|
||||
<h2 className="font-bold text-gray-800 line-clamp-2 leading-tight text-lg capitalize">{course.name}</h2>
|
||||
<p className='text-sm text-gray-700 leading-normal line-clamp-3'>{course.description}</p>
|
||||
<div className='flex flex-col w-full p-4 space-y-3'>
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-bold text-gray-800 leading-tight text-base min-h-[2.75rem] line-clamp-2">{course.name}</h2>
|
||||
<p className='text-xs text-gray-700 leading-normal min-h-[3.75rem] line-clamp-3'>{course.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{course.update_date && (
|
||||
<div className="inline-flex h-5 min-w-[140px] items-center justify-center px-2 rounded-md bg-gray-100/80 border border-gray-200">
|
||||
<span className="text-[10px] font-medium text-gray-600 truncate">
|
||||
Updated {new Date(course.update_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayedAuthors.length > 0 && (
|
||||
<div className="flex -space-x-4 items-center">
|
||||
{displayedAuthors.map((author, index) => (
|
||||
<div
|
||||
key={author.user.user_uuid}
|
||||
className="relative"
|
||||
style={{ zIndex: displayedAuthors.length - index }}
|
||||
>
|
||||
<UserAvatar
|
||||
border="border-2"
|
||||
rounded="rounded-full"
|
||||
avatar_url={author.user.avatar_image ? getUserAvatarMediaDirectory(author.user.user_uuid, author.user.avatar_image) : ''}
|
||||
predefined_avatar={author.user.avatar_image ? undefined : 'empty'}
|
||||
width={32}
|
||||
showProfilePopup={true}
|
||||
userId={author.user.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{hasMoreAuthors && (
|
||||
<div
|
||||
className="relative -ml-1"
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
<div className="flex items-center justify-center w-[32px] h-[32px] text-[11px] font-medium text-gray-600 bg-gray-100 border-2 border-white rounded-full">
|
||||
+{remainingAuthorsCount}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
prefetch
|
||||
href={customLink ? customLink : getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)}
|
||||
className="inline-flex items-center justify-center w-full px-3 py-1.5 bg-black text-white text-xs font-medium rounded-lg hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
Start Learning
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import AuthenticatedClientElement from '@components/Security/AuthenticatedClient
|
|||
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import { deleteCourseFromBackend } from '@services/courses/courses'
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
||||
import { getCourseThumbnailMediaDirectory, getUserAvatarMediaDirectory } from '@services/media/media'
|
||||
import { revalidateTags } from '@services/utils/ts/requests'
|
||||
import { BookMinus, FilePenLine, Settings2, MoreVertical } from 'lucide-react'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
|
|
@ -12,6 +12,7 @@ import Link from 'next/link'
|
|||
import { useRouter } from 'next/navigation'
|
||||
import React from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import UserAvatar from '@components/Objects/UserAvatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -26,6 +27,18 @@ type Course = {
|
|||
thumbnail_image: string
|
||||
org_id: string
|
||||
update_date: string
|
||||
authors?: Array<{
|
||||
user: {
|
||||
id: string
|
||||
user_uuid: string
|
||||
avatar_image: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
username: string
|
||||
}
|
||||
authorship: 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER'
|
||||
authorship_status: 'ACTIVE' | 'INACTIVE' | 'PENDING'
|
||||
}>
|
||||
}
|
||||
|
||||
type PropsType = {
|
||||
|
|
@ -94,6 +107,11 @@ const CourseThumbnailLanding: React.FC<PropsType> = ({ course, orgslug, customLi
|
|||
const org = useOrg() as any
|
||||
const session = useLHSession() as any
|
||||
|
||||
const activeAuthors = course.authors?.filter(author => author.authorship_status === 'ACTIVE') || []
|
||||
const displayedAuthors = activeAuthors.slice(0, 3)
|
||||
const hasMoreAuthors = activeAuthors.length > 3
|
||||
const remainingAuthorsCount = activeAuthors.length - 3
|
||||
|
||||
const deleteCourse = async () => {
|
||||
const toastId = toast.loading('Deleting course...')
|
||||
try {
|
||||
|
|
@ -131,7 +149,7 @@ const CourseThumbnailLanding: React.FC<PropsType> = ({ course, orgslug, customLi
|
|||
<p className='text-xs text-gray-700 leading-normal min-h-[3.75rem] line-clamp-3'>{course.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{course.update_date && (
|
||||
<div className="inline-flex h-5 min-w-[140px] items-center justify-center px-2 rounded-md bg-gray-100/80 border border-gray-200">
|
||||
<span className="text-[10px] font-medium text-gray-600 truncate">
|
||||
|
|
@ -139,6 +157,38 @@ const CourseThumbnailLanding: React.FC<PropsType> = ({ course, orgslug, customLi
|
|||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayedAuthors.length > 0 && (
|
||||
<div className="flex -space-x-4 items-center">
|
||||
{displayedAuthors.map((author, index) => (
|
||||
<div
|
||||
key={author.user.user_uuid}
|
||||
className="relative"
|
||||
style={{ zIndex: displayedAuthors.length - index }}
|
||||
>
|
||||
<UserAvatar
|
||||
border="border-2"
|
||||
rounded="rounded-full"
|
||||
avatar_url={author.user.avatar_image ? getUserAvatarMediaDirectory(author.user.user_uuid, author.user.avatar_image) : ''}
|
||||
predefined_avatar={author.user.avatar_image ? undefined : 'empty'}
|
||||
width={32}
|
||||
showProfilePopup={true}
|
||||
userId={author.user.id}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{hasMoreAuthors && (
|
||||
<div
|
||||
className="relative -ml-1"
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
<div className="flex items-center justify-center w-[32px] h-[32px] text-[11px] font-medium text-gray-600 bg-gray-100 border-2 border-white rounded-full">
|
||||
+{remainingAuthorsCount}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
|
|
|
|||
167
apps/web/components/Pages/Activity/ActivityChapterDropdown.tsx
Normal file
167
apps/web/components/Pages/Activity/ActivityChapterDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
apps/web/components/Pages/Activity/ActivityNavigation.tsx
Normal file
211
apps/web/components/Pages/Activity/ActivityNavigation.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
79
apps/web/components/Pages/Activity/CourseEndView.tsx
Normal file
79
apps/web/components/Pages/Activity/CourseEndView.tsx
Normal 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;
|
||||
187
apps/web/components/Pages/Activity/FixedActivitySecondaryBar.tsx
Normal file
187
apps/web/components/Pages/Activity/FixedActivitySecondaryBar.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import { useRouter } from 'next/navigation'
|
|||
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import { deleteActivity, updateActivity } from '@services/courses/activities'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { useCourse } from '@components/Contexts/CourseContext'
|
||||
|
||||
interface ModifiedActivityInterface {
|
||||
activityId: string
|
||||
|
|
@ -33,10 +34,12 @@ function Activity(props: any) {
|
|||
const [selectedActivity, setSelectedActivity] = React.useState<
|
||||
string | undefined
|
||||
>(undefined)
|
||||
const course = useCourse() as any;
|
||||
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
|
||||
|
||||
async function removeActivity() {
|
||||
await deleteActivity(props.activity.id, session.data?.tokens?.access_token)
|
||||
mutate(`${getAPIUrl()}chapters/meta/course_${props.courseid}`)
|
||||
mutate(`${getAPIUrl()}chapters/meta/course_${props.courseid}?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
await revalidateTags(['courses'], props.orgslug)
|
||||
router.refresh()
|
||||
}
|
||||
|
|
@ -52,7 +55,7 @@ function Activity(props: any) {
|
|||
}
|
||||
|
||||
await updateActivity(modifiedActivityCopy, activityId, session.data?.tokens?.access_token)
|
||||
await mutate(`${getAPIUrl()}chapters/meta/course_${props.courseid}`)
|
||||
await mutate(`${getAPIUrl()}chapters/meta/course_${props.courseid}?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
await revalidateTags(['courses'], props.orgslug)
|
||||
router.refresh()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { mutate } from 'swr'
|
|||
import { getAPIUrl } from '@services/config/config'
|
||||
import { revalidateTags } from '@services/utils/ts/requests'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
|
||||
import { useCourse } from '@components/Contexts/CourseContext'
|
||||
interface ModifiedChapterInterface {
|
||||
chapterId: string
|
||||
chapterName: string
|
||||
|
|
@ -25,6 +25,8 @@ function Chapter(props: any) {
|
|||
const [selectedChapter, setSelectedChapter] = React.useState<
|
||||
string | undefined
|
||||
>(undefined)
|
||||
const course = useCourse() as any;
|
||||
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
|
||||
|
||||
async function updateChapterName(chapterId: string) {
|
||||
if (modifiedChapter?.chapterId === chapterId) {
|
||||
|
|
@ -32,7 +34,7 @@ function Chapter(props: any) {
|
|||
name: modifiedChapter.chapterName,
|
||||
}
|
||||
await updateChapter(chapterId, modifiedChapterCopy, session.data?.tokens?.access_token)
|
||||
await mutate(`${getAPIUrl()}chapters/course/${props.course_uuid}/meta`)
|
||||
await mutate(`${getAPIUrl()}chapters/course/${props.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
|
||||
await revalidateTags(['courses'], props.orgslug)
|
||||
router.refresh()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,7 +33,11 @@
|
|||
"@stitches/react": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@tiptap/core": "^2.11.7",
|
||||
"@tiptap/extension-bullet-list": "^2.11.7",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.11.7",
|
||||
"@tiptap/extension-link": "^2.11.7",
|
||||
"@tiptap/extension-list-item": "^2.11.7",
|
||||
"@tiptap/extension-ordered-list": "^2.11.7",
|
||||
"@tiptap/extension-table": "^2.11.7",
|
||||
"@tiptap/extension-table-cell": "^2.11.7",
|
||||
"@tiptap/extension-table-header": "^2.11.7",
|
||||
|
|
|
|||
37
apps/web/pnpm-lock.yaml
generated
37
apps/web/pnpm-lock.yaml
generated
|
|
@ -78,9 +78,21 @@ importers:
|
|||
'@tiptap/core':
|
||||
specifier: ^2.11.7
|
||||
version: 2.11.7(@tiptap/pm@2.11.7)
|
||||
'@tiptap/extension-bullet-list':
|
||||
specifier: ^2.11.7
|
||||
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
||||
'@tiptap/extension-code-block-lowlight':
|
||||
specifier: ^2.11.7
|
||||
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/extension-code-block@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||
'@tiptap/extension-link':
|
||||
specifier: ^2.11.7
|
||||
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
||||
'@tiptap/extension-list-item':
|
||||
specifier: ^2.11.7
|
||||
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
||||
'@tiptap/extension-ordered-list':
|
||||
specifier: ^2.11.7
|
||||
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
||||
'@tiptap/extension-table':
|
||||
specifier: ^2.11.7
|
||||
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
||||
|
|
@ -1490,6 +1502,12 @@ packages:
|
|||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-link@2.11.7':
|
||||
resolution: {integrity: sha512-qKIowE73aAUrnQCIifYP34xXOHOsZw46cT/LBDlb0T60knVfQoKVE4ku08fJzAV+s6zqgsaaZ4HVOXkQYLoW7g==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
'@tiptap/pm': ^2.7.0
|
||||
|
||||
'@tiptap/extension-list-item@2.11.7':
|
||||
resolution: {integrity: sha512-6ikh7Y+qAbkSuIHXPIINqfzmWs5uIGrylihdZ9adaIyvrN1KSnWIqrZIk/NcZTg5YFIJlXrnGSRSjb/QM3WUhw==}
|
||||
peerDependencies:
|
||||
|
|
@ -2646,6 +2664,9 @@ packages:
|
|||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
linkifyjs@4.2.0:
|
||||
resolution: {integrity: sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw==}
|
||||
|
||||
load-script@1.0.0:
|
||||
resolution: {integrity: sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==}
|
||||
|
||||
|
|
@ -3306,8 +3327,8 @@ packages:
|
|||
tailwind-merge@2.6.0:
|
||||
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
||||
|
||||
tailwind-merge@3.1.0:
|
||||
resolution: {integrity: sha512-aV27Oj8B7U/tAOMhJsSGdWqelfmudnGMdXIlMnk1JfsjwSjts6o8HyfN7SFH3EztzH4YH8kk6GbLTHzITJO39Q==}
|
||||
tailwind-merge@3.2.0:
|
||||
resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==}
|
||||
|
||||
tailwindcss-animate@1.0.7:
|
||||
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
|
||||
|
|
@ -4665,6 +4686,12 @@ snapshots:
|
|||
dependencies:
|
||||
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
|
||||
|
||||
'@tiptap/extension-link@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
|
||||
'@tiptap/pm': 2.11.7
|
||||
linkifyjs: 4.2.0
|
||||
|
||||
'@tiptap/extension-list-item@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
|
||||
|
|
@ -5280,7 +5307,7 @@ snapshots:
|
|||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
react-easy-sort: 1.6.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
tailwind-merge: 3.1.0
|
||||
tailwind-merge: 3.2.0
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
|
@ -6012,6 +6039,8 @@ snapshots:
|
|||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
linkifyjs@4.2.0: {}
|
||||
|
||||
load-script@1.0.0: {}
|
||||
|
||||
locate-path@6.0.0:
|
||||
|
|
@ -6772,7 +6801,7 @@ snapshots:
|
|||
|
||||
tailwind-merge@2.6.0: {}
|
||||
|
||||
tailwind-merge@3.1.0: {}
|
||||
tailwind-merge@3.2.0: {}
|
||||
|
||||
tailwindcss-animate@1.0.7(tailwindcss@4.1.3):
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@ export async function createFileActivity(
|
|||
if (type === 'video') {
|
||||
formData.append('name', data.name)
|
||||
formData.append('video_file', file)
|
||||
// Add video details
|
||||
if (data.details) {
|
||||
formData.append('details', JSON.stringify({
|
||||
startTime: data.details.startTime || 0,
|
||||
endTime: data.details.endTime || null,
|
||||
autoplay: data.details.autoplay || false,
|
||||
muted: data.details.muted || false
|
||||
}))
|
||||
}
|
||||
endpoint = `${getAPIUrl()}activities/video`
|
||||
} else if (type === 'documentpdf') {
|
||||
formData.append('pdf_file', file)
|
||||
|
|
@ -66,6 +75,23 @@ export async function createExternalVideoActivity(
|
|||
data.chapter_id = chapter_id
|
||||
data.activity_id = activity.id
|
||||
|
||||
// Add video details with null checking
|
||||
const defaultDetails = {
|
||||
startTime: 0,
|
||||
endTime: null,
|
||||
autoplay: false,
|
||||
muted: false
|
||||
}
|
||||
|
||||
const videoDetails = data.details ? {
|
||||
startTime: data.details.startTime ?? defaultDetails.startTime,
|
||||
endTime: data.details.endTime ?? defaultDetails.endTime,
|
||||
autoplay: data.details.autoplay ?? defaultDetails.autoplay,
|
||||
muted: data.details.muted ?? defaultDetails.muted
|
||||
} : defaultDetails
|
||||
|
||||
data.details = JSON.stringify(videoDetails)
|
||||
|
||||
const result = await fetch(
|
||||
`${getAPIUrl()}activities/external_video`,
|
||||
RequestBodyWithAuthHeader('POST', data, null, access_token)
|
||||
|
|
|
|||
|
|
@ -36,3 +36,17 @@ export async function markActivityAsComplete(
|
|||
const res = await errorHandling(result)
|
||||
return res
|
||||
}
|
||||
|
||||
export async function unmarkActivityAsComplete(
|
||||
org_slug: string,
|
||||
course_uuid: string,
|
||||
activity_uuid: string,
|
||||
access_token: any
|
||||
) {
|
||||
const result: any = await fetch(
|
||||
`${getAPIUrl()}trail/remove_activity/${activity_uuid}`,
|
||||
RequestBodyWithAuthHeader('DELETE', null, null, access_token)
|
||||
)
|
||||
const res = await errorHandling(result)
|
||||
return res
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue