From 1350cb73546f11f38120550a1dfb7c5c5bbf13b4 Mon Sep 17 00:00:00 2001 From: swve Date: Wed, 16 Apr 2025 15:24:40 +0200 Subject: [PATCH 01/15] feat: implement activity removal from trail and update UI for unmarking activities --- apps/api/src/routers/trail.py | 14 ++++ apps/api/src/services/trail/trail.py | 73 +++++++++++++++++++ .../activity/[activityid]/activity.tsx | 55 ++++++++++++-- apps/web/services/courses/activity.ts | 14 ++++ 4 files changed, 150 insertions(+), 6 deletions(-) diff --git a/apps/api/src/routers/trail.py b/apps/api/src/routers/trail.py index cb9b3d48..1dedc12a 100644 --- a/apps/api/src/routers/trail.py +++ b/apps/api/src/routers/trail.py @@ -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) diff --git a/apps/api/src/services/trail/trail.py b/apps/api/src/services/trail/trail.py index 7daebdea..30a5bffc 100644 --- a/apps/api/src/services/trail/trail.py +++ b/apps/api/src/services/trail/trail.py @@ -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, diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx index 6ae554ff..b7f70ecc 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx @@ -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,7 @@ 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' interface ActivityClientProps { activityid: string @@ -282,6 +283,26 @@ export function MarkStatus(props: { } } + async function unmarkActivityAsCompleteFront() { + try { + setIsLoading(true); + const trail = await unmarkActivityAsComplete( + props.orgslug, + props.course.course_uuid, + props.activity.activity_uuid, + session.data?.tokens?.access_token + ); + + // Mutate the course data to trigger re-render + await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`); + router.refresh(); + } catch (error) { + toast.error('Failed to unmark activity as complete'); + } finally { + setIsLoading(false); + } + } + const isActivityCompleted = () => { let run = props.course.trail.runs.find( (run: any) => run.course_id == props.course.id @@ -296,11 +317,33 @@ export function MarkStatus(props: { return ( <> {isActivityCompleted() ? ( -
- - - {' '} - Complete +
+
+ + + {' '} + Complete +
+ +
+ {isLoading ? ( +
+ + + + +
+ ) : ( + + )} +
+
) : (
Date: Thu, 17 Apr 2025 15:25:29 +0200 Subject: [PATCH 02/15] feat: add link extension support and styling to editor components --- .../Activities/DynamicCanva/DynamicCanva.tsx | 15 +++ apps/web/components/Objects/Editor/Editor.tsx | 18 ++- .../components/Objects/Editor/EditorConf.ts | 59 +++++++++ .../Editor/Toolbar/LinkInputTooltip.tsx | 118 ++++++++++++++++++ .../Objects/Editor/Toolbar/ToolbarButtons.tsx | 63 ++++++++++ apps/web/package.json | 1 + apps/web/pnpm-lock.yaml | 28 ++++- 7 files changed, 297 insertions(+), 5 deletions(-) create mode 100644 apps/web/components/Objects/Editor/EditorConf.ts create mode 100644 apps/web/components/Objects/Editor/Toolbar/LinkInputTooltip.tsx diff --git a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx index 51672575..c9496399 100644 --- a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx +++ b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx @@ -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 @@ -112,6 +113,7 @@ function Canva(props: Editor) { Table.configure({ resizable: true, }), + getLinkExtension(), TableRow, TableHeader, TableCell, @@ -194,6 +196,19 @@ 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; diff --git a/apps/web/components/Objects/Editor/Editor.tsx b/apps/web/components/Objects/Editor/Editor.tsx index 51422ce1..a7071e98 100644 --- a/apps/web/components/Objects/Editor/Editor.tsx +++ b/apps/web/components/Objects/Editor/Editor.tsx @@ -32,6 +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 @@ -151,6 +153,7 @@ function Editor(props: Editor) { TableRow, TableHeader, TableCell, + getLinkExtension(), ], content: props.content, immediatelyRender: false, @@ -204,7 +207,7 @@ function Editor(props: Editor) { props.org?.org_uuid, props.course.course_uuid, props.course.thumbnail_image - ) : getUriWithOrg(props.org?.slug,'/empty_thumbnail.png')}`} + ) : getUriWithOrg(props.org?.slug, '/empty_thumbnail.png')}`} alt="" > @@ -459,6 +462,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; diff --git a/apps/web/components/Objects/Editor/EditorConf.ts b/apps/web/components/Objects/Editor/EditorConf.ts new file mode 100644 index 00000000..220c5038 --- /dev/null +++ b/apps/web/components/Objects/Editor/EditorConf.ts @@ -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 + } + }, + }) +} \ No newline at end of file diff --git a/apps/web/components/Objects/Editor/Toolbar/LinkInputTooltip.tsx b/apps/web/components/Objects/Editor/Toolbar/LinkInputTooltip.tsx new file mode 100644 index 00000000..1ad2258f --- /dev/null +++ b/apps/web/components/Objects/Editor/Toolbar/LinkInputTooltip.tsx @@ -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 = ({ 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 ( + +
+ setUrl(e.target.value)} + autoFocus + /> + + + + + + + + +
+
+ ) +} + +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 \ No newline at end of file diff --git a/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx b/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx index 320d36b7..c36871b7 100644 --- a/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx +++ b/apps/web/components/Objects/Editor/Toolbar/ToolbarButtons.tsx @@ -23,6 +23,7 @@ import { FileText, ImagePlus, Lightbulb, + Link2, MousePointerClick, Sigma, Table, @@ -34,9 +35,12 @@ import { 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 [showLinkInput, setShowLinkInput] = React.useState(false) + const linkButtonRef = React.useRef(null) if (!editor) { return null @@ -83,6 +87,47 @@ export const ToolbarButtons = ({ editor, props }: any) => { } ] + 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 ( editor.chain().focus().undo().run()}> @@ -185,6 +230,24 @@ export const ToolbarButtons = ({ editor, props }: any) => { + +
+ + + + {showLinkInput && ( + + )} +
+
diff --git a/apps/web/package.json b/apps/web/package.json index ca1526a6..2ba0fb75 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "@tanstack/react-table": "^8.21.2", "@tiptap/core": "^2.11.7", "@tiptap/extension-code-block-lowlight": "^2.11.7", + "@tiptap/extension-link": "^2.11.7", "@tiptap/extension-table": "^2.11.7", "@tiptap/extension-table-cell": "^2.11.7", "@tiptap/extension-table-header": "^2.11.7", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 5fb87869..03818c2a 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -81,6 +81,9 @@ importers: '@tiptap/extension-code-block-lowlight': specifier: ^2.11.7 version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/extension-code-block@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(highlight.js@11.11.1)(lowlight@3.3.0) + '@tiptap/extension-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-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 +1493,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 +2655,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 +3318,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 +4677,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 +5298,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 +6030,8 @@ snapshots: dependencies: uc.micro: 2.1.0 + linkifyjs@4.2.0: {} + load-script@1.0.0: {} locate-path@6.0.0: @@ -6772,7 +6792,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: From 46e06201fb84247705e0f03e79c143197793f0a1 Mon Sep 17 00:00:00 2001 From: swve Date: Thu, 17 Apr 2025 15:57:57 +0200 Subject: [PATCH 03/15] feat: unpublished activities are now hidden by default --- apps/api/src/routers/courses/courses.py | 3 +- .../services/courses/activities/activities.py | 12 +++-- apps/api/src/services/courses/chapters.py | 3 +- apps/api/src/services/courses/courses.py | 3 +- .../course/[courseuuid]/[subpage]/page.tsx | 2 +- .../web/components/Contexts/CourseContext.tsx | 7 +-- .../components/Dashboard/Misc/SaveState.tsx | 53 +++++++++++++------ .../EditCourseGeneral/ThumbnailUpdate.tsx | 3 +- .../Buttons/NewActivityButton.tsx | 7 +-- .../DraggableElements/ActivityElement.tsx | 8 +-- .../DraggableElements/ChapterElement.tsx | 7 ++- .../EditCourseStructure.tsx | 4 +- .../Pages/CourseEdit/Draggables/Activity.tsx | 7 ++- .../Pages/CourseEdit/Draggables/Chapter.tsx | 6 ++- 14 files changed, 83 insertions(+), 42 deletions(-) diff --git a/apps/api/src/routers/courses/courses.py b/apps/api/src/routers/courses/courses.py index 19042524..f2271983 100644 --- a/apps/api/src/routers/courses/courses.py +++ b/apps/api/src/routers/courses/courses.py @@ -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 ) diff --git a/apps/api/src/services/courses/activities/activities.py b/apps/api/src/services/courses/activities/activities.py index f20f51fb..42ad1f05 100644 --- a/apps/api/src/services/courses/activities/activities.py +++ b/apps/api/src/services/courses/activities/activities.py @@ -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 diff --git a/apps/api/src/services/courses/chapters.py b/apps/api/src/services/courses/chapters.py index 97afcf92..4a30bb3d 100644 --- a/apps/api/src/services/courses/chapters.py +++ b/apps/api/src/services/courses/chapters.py @@ -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() diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index 53d11fd2..444a8e4e 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -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(): diff --git a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx index a095945a..00eb734a 100644 --- a/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/courses/course/[courseuuid]/[subpage]/page.tsx @@ -25,7 +25,7 @@ function CourseOverviewPage(props: { params: Promise }) { return (
- +
diff --git a/apps/web/components/Contexts/CourseContext.tsx b/apps/web/components/Contexts/CourseContext.tsx index 5bfaea91..238157d2 100644 --- a/apps/web/components/Contexts/CourseContext.tsx +++ b/apps/web/components/Contexts/CourseContext.tsx @@ -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; diff --git a/apps/web/components/Dashboard/Misc/SaveState.tsx b/apps/web/components/Dashboard/Misc/SaveState.tsx index a773e74b..672fd36c 100644 --- a/apps/web/components/Dashboard/Misc/SaveState.tsx +++ b/apps/web/components/Dashboard/Misc/SaveState.tsx @@ -6,37 +6,43 @@ import { useCourse, useCourseDispatch, } from '@components/Contexts/CourseContext' -import { Check, SaveAllIcon, Timer } from 'lucide-react' +import { Check, SaveAllIcon, Timer, Loader2 } from 'lucide-react' import { useRouter } from 'next/navigation' -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { mutate } from 'swr' import { updateCourse } from '@services/courses/courses' import { useLHSession } from '@components/Contexts/LHSessionContext' function SaveState(props: { orgslug: string }) { + const [isLoading, setIsLoading] = useState(false) const course = useCourse() as any const session = useLHSession() as any; const router = useRouter() const saved = course ? course.isSaved : true const dispatchCourse = useCourseDispatch() as any const course_structure = course.courseStructure - + const withUnpublishedActivities = course ? course.withUnpublishedActivities : false const saveCourseState = async () => { - // Course order - if (saved) return - await changeOrderBackend() - mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`) - // Course metadata - await changeMetadataBackend() - mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`) - await revalidateTags(['courses'], props.orgslug) - dispatchCourse({ type: 'setIsSaved' }) + if (saved || isLoading) return + setIsLoading(true) + try { + // Course order + await changeOrderBackend() + mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`) + // Course metadata + await changeMetadataBackend() + mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`) + await revalidateTags(['courses'], props.orgslug) + dispatchCourse({ type: 'setIsSaved' }) + } finally { + setIsLoading(false) + } } // // Course Order const changeOrderBackend = async () => { - mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`) + mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`) await updateCourseOrderStructure( course.courseStructure.course_uuid, course.courseOrder, @@ -49,7 +55,7 @@ function SaveState(props: { orgslug: string }) { // Course metadata const changeMetadataBackend = async () => { - mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`) + mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`) await updateCourse( course.courseStructure.course_uuid, course.courseStructure, @@ -117,12 +123,25 @@ function SaveState(props: { orgslug: string }) { `px-4 py-2 rounded-lg drop-shadow-md cursor-pointer flex space-x-2 items-center font-bold antialiased transition-all ease-linear ` + (saved ? 'bg-gray-600 text-white' - : 'bg-black text-white border hover:bg-gray-900 ') + : 'bg-black text-white border hover:bg-gray-900 ') + + (isLoading ? 'opacity-50 cursor-not-allowed' : '') } onClick={saveCourseState} > - {saved ? : } - {saved ?
Saved
:
Save
} + {isLoading ? ( + + ) : saved ? ( + + ) : ( + + )} + {isLoading ? ( +
Saving...
+ ) : saved ? ( +
Saved
+ ) : ( +
Save
+ )}
) diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx index 3b7a77ef..461508d5 100644 --- a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx @@ -17,6 +17,7 @@ function ThumbnailUpdate() { const [isLoading, setIsLoading] = React.useState(false) as any const [error, setError] = React.useState('') as any const [showUnsplashPicker, setShowUnsplashPicker] = useState(false) + const withUnpublishedActivities = course ? course.withUnpublishedActivities : false const handleFileChange = async (event: any) => { const file = event.target.files[0] @@ -40,7 +41,7 @@ function ThumbnailUpdate() { file, session.data?.tokens?.access_token ) - mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`) + mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`) // wait for 1 second to show loading animation await new Promise((r) => setTimeout(r, 1500)) if (res.success === false) { diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/Buttons/NewActivityButton.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/Buttons/NewActivityButton.tsx index e955c0cc..b970075c 100644 --- a/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/Buttons/NewActivityButton.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/Buttons/NewActivityButton.tsx @@ -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') diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx index de2b844e..88ce81dc 100644 --- a/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ActivityElement.tsx @@ -56,6 +56,8 @@ function ActivityElement(props: ActivitiyElementProps) { const [isUpdatingName, setIsUpdatingName] = React.useState(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() diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ChapterElement.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ChapterElement.tsx index 4cbe84e6..a6235893 100644 --- a/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ChapterElement.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/DraggableElements/ChapterElement.tsx @@ -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() } diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure.tsx index ed222c12..f65e11f0 100644 --- a/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure.tsx @@ -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) diff --git a/apps/web/components/Pages/CourseEdit/Draggables/Activity.tsx b/apps/web/components/Pages/CourseEdit/Draggables/Activity.tsx index 6e5df6bb..55c87bb3 100644 --- a/apps/web/components/Pages/CourseEdit/Draggables/Activity.tsx +++ b/apps/web/components/Pages/CourseEdit/Draggables/Activity.tsx @@ -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() } diff --git a/apps/web/components/Pages/CourseEdit/Draggables/Chapter.tsx b/apps/web/components/Pages/CourseEdit/Draggables/Chapter.tsx index 19e1452b..b93cac10 100644 --- a/apps/web/components/Pages/CourseEdit/Draggables/Chapter.tsx +++ b/apps/web/components/Pages/CourseEdit/Draggables/Chapter.tsx @@ -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() } From 3d489c599ef8be8388943dfa45dbf3164a885cd8 Mon Sep 17 00:00:00 2001 From: swve Date: Thu, 17 Apr 2025 16:38:05 +0200 Subject: [PATCH 04/15] feat: better activity sticky topbar --- .../activity/[activityid]/activity.tsx | 639 +++++------------- .../Activity/ActivityChapterDropdown.tsx | 167 +++++ .../Pages/Activity/ActivityNavigation.tsx | 211 ++++++ .../Activity/FixedActivitySecondaryBar.tsx | 187 +++++ 4 files changed, 725 insertions(+), 479 deletions(-) create mode 100644 apps/web/components/Dashboard/Pages/Activity/ActivityChapterDropdown.tsx create mode 100644 apps/web/components/Dashboard/Pages/Activity/ActivityNavigation.tsx create mode 100644 apps/web/components/Dashboard/Pages/Activity/FixedActivitySecondaryBar.tsx diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx index b7f70ecc..e665561c 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx @@ -29,6 +29,9 @@ 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/Dashboard/Pages/Activity/ActivityNavigation' +import ActivityChapterDropdown from '@components/Dashboard/Pages/Activity/ActivityChapterDropdown' +import FixedActivitySecondaryBar from '@components/Dashboard/Pages/Activity/FixedActivitySecondaryBar' interface ActivityClientProps { activityid: string @@ -92,158 +95,167 @@ function ActivityClient(props: ActivityClientProps) { -
-
-
-
- - - -
-
-

Course

-

- {course.name} -

-
-
-
- - -
-
- -
-

- Chapter : {getChapterNameByActivityId(course, activity.id)} -

-

- {activity.name} -

-
-
-
- {activity && activity.published == true && activity.content.paid_access != false && ( - - {activity.activity_type != 'TYPE_ASSIGNMENT' && - <> - - {contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && ( - - - Contribute to Activity - - )} - - + + +
+
+
+
+
+ + - - } - {activity.activity_type == 'TYPE_ASSIGNMENT' && - <> - - - - - - } - - )} -
-
- {activity && activity.published == false && ( -
-
-

- This activity is not published yet -

-
-
- )} - - {activity && activity.published == true && ( - <> - {activity.content.paid_access == false ? ( - - ) : ( -
- {/* Activity Types */} -
- {activity.activity_type == 'TYPE_DYNAMIC' && ( - - )} - {activity.activity_type == 'TYPE_VIDEO' && ( - - )} - {activity.activity_type == 'TYPE_DOCUMENT' && ( - - )} - {activity.activity_type == 'TYPE_ASSIGNMENT' && ( -
- {assignment ? ( - - - - - - - - ) : ( -
- )} -
- )} + +
+
+

Course

+

+ {course.name} +

- )} - - )} +
+ + + +
+
+ +
+

+ Chapter : {getChapterNameByActivityId(course, activity.id)} +

+

+ {activity.name} +

+
+
+
+ {activity && activity.published == true && activity.content.paid_access != false && ( + + {activity.activity_type != 'TYPE_ASSIGNMENT' && ( + <> + + {contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && ( + + + Contribute to Activity + + )} + + + + )} + {activity.activity_type == 'TYPE_ASSIGNMENT' && ( + <> + + + + + + )} + + )} +
+
+
+ {activity && activity.published == false && ( +
+
+

+ This activity is not published yet +

+
+
+ )} + + {activity && activity.published == true && ( + <> + {activity.content.paid_access == false ? ( + + ) : ( +
+ {/* Activity Types */} +
+ {activity.activity_type == 'TYPE_DYNAMIC' && ( + + )} + {activity.activity_type == 'TYPE_VIDEO' && ( + + )} + {activity.activity_type == 'TYPE_DOCUMENT' && ( + + )} + {activity.activity_type == 'TYPE_ASSIGNMENT' && ( +
+ {assignment ? ( + + + + + + + + ) : ( +
+ )} +
+ )} +
+
+ )} + + )} - {/* Activity Navigation */} - {activity && activity.published == true && activity.content.paid_access != false && ( - - )} - - {
} + + {/* Fixed Activity Secondary Bar */} + {activity && activity.published == true && activity.content.paid_access != false && ( + + )} + +
+
@@ -485,335 +497,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(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
) : ( diff --git a/apps/web/components/Dashboard/Pages/Activity/FixedActivitySecondaryBar.tsx b/apps/web/components/Dashboard/Pages/Activity/FixedActivitySecondaryBar.tsx index 56ecdf43..41679d4e 100644 --- a/apps/web/components/Dashboard/Pages/Activity/FixedActivitySecondaryBar.tsx +++ b/apps/web/components/Dashboard/Pages/Activity/FixedActivitySecondaryBar.tsx @@ -125,8 +125,8 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB orgslug={props.orgslug} />
-

Course

-

+

Course

+

{props.course.name}

@@ -145,16 +145,16 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB disabled={!prevActivity} title={prevActivity ? `Previous: ${prevActivity.name}` : 'No previous activity'} > - +
- Previous - + Previous + {prevActivity ? prevActivity.name : 'No previous activity'}
- + {currentIndex + 1} of {allActivities.length} @@ -169,12 +169,12 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB title={nextActivity ? `Next: ${nextActivity.name}` : 'No next activity'} >
- Next - + Next + {nextActivity ? nextActivity.name : 'No next activity'}
- +
From 8c43f09a2f6facda629bc3786b67101519dea0d4 Mon Sep 17 00:00:00 2001 From: swve Date: Fri, 18 Apr 2025 17:47:54 +0200 Subject: [PATCH 07/15] feat: add Next Activity button to activity component for improved navigation --- .../activity/[activityid]/activity.tsx | 89 +++++++++++++++---- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx index 81b21e8d..56f9fb90 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx @@ -364,31 +364,86 @@ export function MarkStatus(props: { status="warning" />
+
) : ( -
- {isLoading ? ( -
- - - - -
- ) : ( - - - - )}{' '} - {!isMobile && {isLoading ? 'Marking...' : 'Mark as complete'}} +
+
+ {isLoading ? ( +
+ + + + +
+ ) : ( + + + + )}{' '} + {!isMobile && {isLoading ? 'Marking...' : 'Mark as complete'}} +
+
)} ) } +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 ( + +
+ {!isMobile && Next} + +
+
+ ); +} + function AssignmentTools(props: { activity: any activityid: string From ed8783d0efc76a75730e982460fce8bcf7dcbc85 Mon Sep 17 00:00:00 2001 From: swve Date: Fri, 18 Apr 2025 18:08:20 +0200 Subject: [PATCH 08/15] feat: Add course completion indicator at the end of the course --- .../activity/[activityid]/activity.tsx | 359 ++++++++++-------- .../Activity/ActivityChapterDropdown.tsx | 0 .../Pages/Activity/ActivityNavigation.tsx | 0 .../Pages/Activity/CourseEndView.tsx | 79 ++++ .../Activity/FixedActivitySecondaryBar.tsx | 0 5 files changed, 284 insertions(+), 154 deletions(-) rename apps/web/components/{Dashboard => }/Pages/Activity/ActivityChapterDropdown.tsx (100%) rename apps/web/components/{Dashboard => }/Pages/Activity/ActivityNavigation.tsx (100%) create mode 100644 apps/web/components/Pages/Activity/CourseEndView.tsx rename apps/web/components/{Dashboard => }/Pages/Activity/FixedActivitySecondaryBar.tsx (100%) diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx index 56f9fb90..885d4ab4 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx @@ -29,9 +29,10 @@ 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/Dashboard/Pages/Activity/ActivityNavigation' -import ActivityChapterDropdown from '@components/Dashboard/Pages/Activity/ActivityChapterDropdown' -import FixedActivitySecondaryBar from '@components/Dashboard/Pages/Activity/FixedActivitySecondaryBar' +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 @@ -95,168 +96,175 @@ function ActivityClient(props: ActivityClientProps) { -
- - -
-
-
-
-
- - - -
-
-

Course

-

- {course.name} -

+ {activityid === 'end' ? ( + + ) : ( +
+
+
+
+
+
+ + + +
+
+

Course

+

+ {course.name} +

+
-
- + -
-
- -
-

- Chapter : {getChapterNameByActivityId(course, activity.id)} -

-

- {activity.name} -

+
+
+ +
+

+ Chapter : {getChapterNameByActivityId(course, activity.id)} +

+

+ {activity.name} +

+
-
-
- {activity && activity.published == true && activity.content.paid_access != false && ( - - {activity.activity_type != 'TYPE_ASSIGNMENT' && ( - <> - - {contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && ( - - - Contribute to Activity - - )} - - - - )} - {activity.activity_type == 'TYPE_ASSIGNMENT' && ( - <> - - - + {activity && activity.published == true && activity.content.paid_access != false && ( + + {activity.activity_type != 'TYPE_ASSIGNMENT' && ( + <> + + {contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && ( + + + Contribute to Activity + + )} + + - - - )} - - )} -
-
-
- {activity && activity.published == false && ( -
-
-

- This activity is not published yet -

-
-
- )} - - {activity && activity.published == true && ( - <> - {activity.content.paid_access == false ? ( - - ) : ( -
- {/* Activity Types */} -
- {activity.activity_type == 'TYPE_DYNAMIC' && ( - - )} - {activity.activity_type == 'TYPE_VIDEO' && ( - - )} - {activity.activity_type == 'TYPE_DOCUMENT' && ( - - )} - {activity.activity_type == 'TYPE_ASSIGNMENT' && ( -
- {assignment ? ( - - - - - - - - ) : ( -
- )} -
- )} -
+ + )} + {activity.activity_type == 'TYPE_ASSIGNMENT' && ( + <> + + + + + + )} + + )}
- )} - - )} - +
+
+ {activity && activity.published == false && ( +
+
+

+ This activity is not published yet +

+
+
+ )} - {/* Fixed Activity Secondary Bar */} - {activity && activity.published == true && activity.content.paid_access != false && ( - - )} + {activity && activity.published == true && ( + <> + {activity.content.paid_access == false ? ( + + ) : ( +
+ {/* Activity Types */} +
+ {activity.activity_type == 'TYPE_DYNAMIC' && ( + + )} + {activity.activity_type == 'TYPE_VIDEO' && ( + + )} + {activity.activity_type == 'TYPE_DOCUMENT' && ( + + )} + {activity.activity_type == 'TYPE_ASSIGNMENT' && ( +
+ {assignment ? ( + + + + + + + + ) : ( +
+ )} +
+ )} +
+
+ )} + + )} -
+ + {/* Fixed Activity Secondary Bar */} + {activity && activity.published == true && activity.content.paid_access != false && ( + + )} + +
+
-
+ )} @@ -275,20 +283,63 @@ 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 to trigger re-render + // Mutate the course data await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`); - router.refresh(); + + 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); diff --git a/apps/web/components/Dashboard/Pages/Activity/ActivityChapterDropdown.tsx b/apps/web/components/Pages/Activity/ActivityChapterDropdown.tsx similarity index 100% rename from apps/web/components/Dashboard/Pages/Activity/ActivityChapterDropdown.tsx rename to apps/web/components/Pages/Activity/ActivityChapterDropdown.tsx diff --git a/apps/web/components/Dashboard/Pages/Activity/ActivityNavigation.tsx b/apps/web/components/Pages/Activity/ActivityNavigation.tsx similarity index 100% rename from apps/web/components/Dashboard/Pages/Activity/ActivityNavigation.tsx rename to apps/web/components/Pages/Activity/ActivityNavigation.tsx diff --git a/apps/web/components/Pages/Activity/CourseEndView.tsx b/apps/web/components/Pages/Activity/CourseEndView.tsx new file mode 100644 index 00000000..027f1a65 --- /dev/null +++ b/apps/web/components/Pages/Activity/CourseEndView.tsx @@ -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 = ({ courseName, orgslug, courseUuid, thumbnailImage }) => { + const { width, height } = useWindowSize(); + const org = useOrg() as any; + + return ( +
+
+ +
+ +
+
+ {thumbnailImage && ( + {courseName} + )} + +
+ +
+
+ +

+ Congratulations! 🎉 +

+ +

+ You've successfully completed + {courseName} +

+ +

+ Your dedication and hard work have paid off. You've mastered all the content in this course. +

+ +
+ + + Back to Course + +
+
+
+ ); +}; + +export default CourseEndView; \ No newline at end of file diff --git a/apps/web/components/Dashboard/Pages/Activity/FixedActivitySecondaryBar.tsx b/apps/web/components/Pages/Activity/FixedActivitySecondaryBar.tsx similarity index 100% rename from apps/web/components/Dashboard/Pages/Activity/FixedActivitySecondaryBar.tsx rename to apps/web/components/Pages/Activity/FixedActivitySecondaryBar.tsx From 3173e6b417c18d9c83064d02f9cecc7121410aef Mon Sep 17 00:00:00 2001 From: swve Date: Tue, 22 Apr 2025 15:53:02 +0200 Subject: [PATCH 09/15] refactor: change video activity modal UI --- .../Objects/Activities/Video/Video.tsx | 41 +++- .../Modals/Activities/Create/NewActivity.tsx | 8 +- ...gnment.tsx => AssignmentActivityModal.tsx} | 0 ...umentPdf.tsx => DocumentActivityModal.tsx} | 0 ...amicCanva.tsx => DynamicActivityModal.tsx} | 0 .../Create/NewActivityModal/Video.tsx | 199 ------------------ .../NewActivityModal/VideoActivityModal.tsx | 187 ++++++++++++++++ 7 files changed, 221 insertions(+), 214 deletions(-) rename apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/{Assignment.tsx => AssignmentActivityModal.tsx} (100%) rename apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/{DocumentPdf.tsx => DocumentActivityModal.tsx} (100%) rename apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/{DynamicCanva.tsx => DynamicActivityModal.tsx} (100%) delete mode 100644 apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Video.tsx create mode 100644 apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx diff --git a/apps/web/components/Objects/Activities/Video/Video.tsx b/apps/web/components/Objects/Activities/Video/Video.tsx index 2ba6b15c..cf09ca56 100644 --- a/apps/web/components/Objects/Activities/Video/Video.tsx +++ b/apps/web/components/Objects/Activities/Video/Video.tsx @@ -3,17 +3,42 @@ 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 VideoActivityProps { + activity: { + activity_sub_type: string + activity_uuid: string + content: { + filename?: string + uri?: string + } + } + course: { + course_uuid: string + } +} + +function VideoActivity({ activity, course }: VideoActivityProps) { const org = useOrg() as any const [videoId, setVideoId] = React.useState('') 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' + ) + } + return (
{activity && ( @@ -24,13 +49,7 @@ function VideoActivity({ activity, course }: { activity: any; course: any }) {
@@ -44,7 +63,7 @@ function VideoActivity({ activity, course }: { activity: any; course: any }) { width: '100%', height: '100%', playerVars: { - autoplay: 0, + autoplay: 0 }, }} videoId={videoId} diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivity.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivity.tsx index ff851a06..9dac7af1 100644 --- a/apps/web/components/Objects/Modals/Activities/Create/NewActivity.tsx +++ b/apps/web/components/Objects/Modals/Activities/Create/NewActivity.tsx @@ -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, diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Assignment.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/AssignmentActivityModal.tsx similarity index 100% rename from apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Assignment.tsx rename to apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/AssignmentActivityModal.tsx diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DocumentPdf.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DocumentActivityModal.tsx similarity index 100% rename from apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DocumentPdf.tsx rename to apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DocumentActivityModal.tsx diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DynamicCanva.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DynamicActivityModal.tsx similarity index 100% rename from apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DynamicCanva.tsx rename to apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/DynamicActivityModal.tsx diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Video.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Video.tsx deleted file mode 100644 index 3cc61bd1..00000000 --- a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/Video.tsx +++ /dev/null @@ -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) => { - setVideo(event.target.files[0]) - } - - const handleNameChange = (event: React.ChangeEvent) => { - setName(event.target.value) - } - - const handleYoutubeUrlChange = ( - event: React.ChangeEvent - ) => { - 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 ( - - - - Video name - - Please provide a name for your video activity - - - - - - -
-
-
-
{ - 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 -
-
{ - 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 -
-
- {selectedView === 'file' && ( -
- - - Video file - - Please provide a video for your activity - - - - - - -
- )} - {selectedView === 'youtube' && ( -
- - - - - YouTube URL - - - Please provide a video for your activity - - - - - - -
- )} -
-
- - - - - {isSubmitting ? ( - - ) : ( - 'Create activity' - )} - - - -
- ) -} - -export default VideoModal diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx new file mode 100644 index 00000000..38e7e7f7 --- /dev/null +++ b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx @@ -0,0 +1,187 @@ +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, Upload } 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) + const [isSubmitting, setIsSubmitting] = useState(false) + const [name, setName] = React.useState('') + const [youtubeUrl, setYoutubeUrl] = React.useState('') + const [selectedView, setSelectedView] = React.useState<'file' | 'youtube'>('file') + + const handleVideoChange = (event: React.ChangeEvent) => { + 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, + }, + chapterId + ) + } + + if (selectedView === 'youtube') { + const external_video_object: ExternalVideoObject = { + name, + type: 'youtube', + uri: youtubeUrl, + chapter_id: chapterId, + } + + await submitExternalVideo( + external_video_object, + 'activity', + chapterId + ) + } + } finally { + setIsSubmitting(false) + } + } + + return ( + + + + Activity Name + + Please provide a name for your video activity + + + + setName(e.target.value)} + type="text" + required + placeholder="Enter activity name..." + /> + + + +
+
+ + +
+ +
+ {selectedView === 'file' && ( +
+ + Video File +
+ +
+
+
+ )} + + {selectedView === 'youtube' && ( +
+ + YouTube URL + + setYoutubeUrl(e.target.value)} + type="text" + required + placeholder="https://youtube.com/watch?v=..." + /> + + +
+ )} +
+
+ + + + + {isSubmitting ? ( + + ) : ( + 'Create Activity' + )} + + + +
+ ) +} + +export default VideoModal From 31b5104dd5bd447eddddbf7784376d9194920afd Mon Sep 17 00:00:00 2001 From: swve Date: Tue, 22 Apr 2025 17:25:41 +0200 Subject: [PATCH 10/15] feat: add details to video activities wip: uploadable video activities --- .../versions/a5afa69dd917_activity_details.py | 31 +++++++ apps/api/src/db/courses/activities.py | 4 + .../routers/courses/activities/activities.py | 4 +- .../src/services/courses/activities/video.py | 6 +- .../Objects/Activities/Video/Video.tsx | 37 +++++++- .../NewActivityModal/VideoActivityModal.tsx | 85 +++++++++++++++++++ apps/web/services/courses/activities.ts | 17 ++++ 7 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 apps/api/migrations/versions/a5afa69dd917_activity_details.py diff --git a/apps/api/migrations/versions/a5afa69dd917_activity_details.py b/apps/api/migrations/versions/a5afa69dd917_activity_details.py new file mode 100644 index 00000000..78531b4b --- /dev/null +++ b/apps/api/migrations/versions/a5afa69dd917_activity_details.py @@ -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 ### diff --git a/apps/api/src/db/courses/activities.py b/apps/api/src/db/courses/activities.py index 50ec31c8..1cb327ea 100644 --- a/apps/api/src/db/courses/activities.py +++ b/apps/api/src/db/courses/activities.py @@ -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 diff --git a/apps/api/src/routers/courses/activities/activities.py b/apps/api/src/routers/courses/activities/activities.py index 3b5bc84a..c8e58c08 100644 --- a/apps/api/src/routers/courses/activities/activities.py +++ b/apps/api/src/routers/courses/activities/activities.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Optional from fastapi import APIRouter, Depends, UploadFile, Form, Request from src.db.courses.activities import ActivityCreate, ActivityRead, ActivityUpdate from src.db.users import PublicUser @@ -113,6 +113,7 @@ async def api_create_video_activity( request: Request, name: str = Form(), chapter_id: str = Form(), + details: Optional[dict] = Form(default=None), 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, ) diff --git a/apps/api/src/services/courses/activities/video.py b/apps/api/src/services/courses/activities/video.py index da428865..1ef9c13f 100644 --- a/apps/api/src/services/courses/activities/video.py +++ b/apps/api/src/services/courses/activities/video.py @@ -1,4 +1,4 @@ -from typing import Literal +from typing import Literal, Optional from src.db.courses.courses import Course from src.db.organizations import Organization @@ -31,6 +31,7 @@ async def create_video_activity( current_user: PublicUser, db_session: Session, video_file: UploadFile | None = None, + details: Optional[dict] = None, ): # RBAC check await rbac_check(request, "activity_x", current_user, "create", db_session) @@ -99,6 +100,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 +146,7 @@ class ExternalVideo(BaseModel): uri: str type: Literal["youtube", "vimeo"] chapter_id: str + details: Optional[dict] = None class ExternalVideoInDB(BaseModel): @@ -194,6 +197,7 @@ async def create_external_video_activity( "type": data.type, "activity_uuid": activity_uuid, }, + details=data.details, version=1, creation_date=str(datetime.now()), update_date=str(datetime.now()), diff --git a/apps/web/components/Objects/Activities/Video/Video.tsx b/apps/web/components/Objects/Activities/Video/Video.tsx index cf09ca56..4c1037a0 100644 --- a/apps/web/components/Objects/Activities/Video/Video.tsx +++ b/apps/web/components/Objects/Activities/Video/Video.tsx @@ -3,6 +3,13 @@ import YouTube from 'react-youtube' import { getActivityMediaDirectory } from '@services/media/media' import { useOrg } from '@components/Contexts/OrgContext' +interface VideoDetails { + startTime?: number + endTime?: number | null + autoplay?: boolean + muted?: boolean +} + interface VideoActivityProps { activity: { activity_sub_type: string @@ -11,6 +18,7 @@ interface VideoActivityProps { filename?: string uri?: string } + details?: VideoDetails } course: { course_uuid: string @@ -20,6 +28,7 @@ interface VideoActivityProps { function VideoActivity({ activity, course }: VideoActivityProps) { const org = useOrg() as any const [videoId, setVideoId] = React.useState('') + const videoRef = React.useRef(null) React.useEffect(() => { if (activity?.content?.uri) { @@ -39,6 +48,26 @@ function VideoActivity({ activity, course }: VideoActivityProps) { ) } + // 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 (
{activity && ( @@ -47,9 +76,12 @@ function VideoActivity({ activity, course }: VideoActivityProps) {
@@ -63,7 +95,10 @@ function VideoActivity({ activity, course }: VideoActivityProps) { 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} diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx index 38e7e7f7..56e51f98 100644 --- a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx +++ b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx @@ -14,11 +14,19 @@ 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({ @@ -32,6 +40,12 @@ function VideoModal({ const [name, setName] = React.useState('') const [youtubeUrl, setYoutubeUrl] = React.useState('') const [selectedView, setSelectedView] = React.useState<'file' | 'youtube'>('file') + const [videoDetails, setVideoDetails] = React.useState({ + startTime: 0, + endTime: null, + autoplay: false, + muted: false + }) const handleVideoChange = (event: React.ChangeEvent) => { if (event.target.files?.[0]) { @@ -56,6 +70,7 @@ function VideoModal({ published_version: 1, version: 1, course_id: course.id, + details: videoDetails }, chapterId ) @@ -67,6 +82,7 @@ function VideoModal({ type: 'youtube', uri: youtubeUrl, chapter_id: chapterId, + details: videoDetails } await submitExternalVideo( @@ -80,6 +96,73 @@ function VideoModal({ } } + const VideoSettingsForm = () => ( +
+

Video Settings

+
+ + Start Time (seconds) + + setVideoDetails({ + ...videoDetails, + startTime: Math.max(0, parseInt(e.target.value) || 0) + })} + placeholder="0" + /> + + + + + End Time (seconds, optional) + + setVideoDetails({ + ...videoDetails, + endTime: e.target.value ? parseInt(e.target.value) : null + })} + placeholder="Leave empty for full duration" + /> + + +
+ +
+ + + +
+
+ ) + return ( @@ -143,6 +226,7 @@ function VideoModal({ />
+
)} @@ -160,6 +244,7 @@ function VideoModal({ /> +
)}
diff --git a/apps/web/services/courses/activities.ts b/apps/web/services/courses/activities.ts index f49324e4..e6209ff3 100644 --- a/apps/web/services/courses/activities.ts +++ b/apps/web/services/courses/activities.ts @@ -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) @@ -65,6 +74,14 @@ export async function createExternalVideoActivity( // add coursechapter_id to data data.chapter_id = chapter_id data.activity_id = activity.id + + // Add video details if provided + data.details = { + startTime: data.startTime || 0, + endTime: data.endTime || null, + autoplay: data.autoplay || false, + muted: data.muted || false + } const result = await fetch( `${getAPIUrl()}activities/external_video`, From 260bd60c7adc54e6eda2321b9c78d8ec33f5d483 Mon Sep 17 00:00:00 2001 From: swve Date: Wed, 23 Apr 2025 17:47:26 +0200 Subject: [PATCH 11/15] fix: update details handling in video activities to use JSON strings --- .../routers/courses/activities/activities.py | 4 +- .../src/services/courses/activities/video.py | 15 +- .../NewActivityModal/VideoActivityModal.tsx | 167 +++++++++--------- apps/web/services/courses/activities.ts | 21 ++- 4 files changed, 108 insertions(+), 99 deletions(-) diff --git a/apps/api/src/routers/courses/activities/activities.py b/apps/api/src/routers/courses/activities/activities.py index c8e58c08..7ac20cf5 100644 --- a/apps/api/src/routers/courses/activities/activities.py +++ b/apps/api/src/routers/courses/activities/activities.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import List from fastapi import APIRouter, Depends, UploadFile, Form, Request from src.db.courses.activities import ActivityCreate, ActivityRead, ActivityUpdate from src.db.users import PublicUser @@ -113,7 +113,7 @@ async def api_create_video_activity( request: Request, name: str = Form(), chapter_id: str = Form(), - details: Optional[dict] = Form(default=None), + details: str = Form(default="{}"), current_user: PublicUser = Depends(get_current_user), video_file: UploadFile | None = None, db_session=Depends(get_db_session), diff --git a/apps/api/src/services/courses/activities/video.py b/apps/api/src/services/courses/activities/video.py index 1ef9c13f..71408033 100644 --- a/apps/api/src/services/courses/activities/video.py +++ b/apps/api/src/services/courses/activities/video.py @@ -1,4 +1,5 @@ -from typing import Literal, Optional +from typing import Literal +import json from src.db.courses.courses import Course from src.db.organizations import Organization @@ -31,7 +32,7 @@ async def create_video_activity( current_user: PublicUser, db_session: Session, video_file: UploadFile | None = None, - details: Optional[dict] = None, + details: str = "{}", ): # RBAC check await rbac_check(request, "activity_x", current_user, "create", db_session) @@ -40,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, @@ -146,7 +150,7 @@ class ExternalVideo(BaseModel): uri: str type: Literal["youtube", "vimeo"] chapter_id: str - details: Optional[dict] = None + details: str = "{}" class ExternalVideoInDB(BaseModel): @@ -184,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, @@ -197,7 +204,7 @@ async def create_external_video_activity( "type": data.type, "activity_uuid": activity_uuid, }, - details=data.details, + details=details, version=1, creation_date=str(datetime.now()), update_date=str(datetime.now()), diff --git a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx index 56e51f98..dfe737b8 100644 --- a/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx +++ b/apps/web/components/Objects/Modals/Activities/Create/NewActivityModal/VideoActivityModal.tsx @@ -1,11 +1,10 @@ -import FormLayout, { - ButtonBlack, - Flex, - FormField, - FormLabel, - FormMessage, - Input, -} from '@components/Objects/StyledElements/Form/Form' +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' @@ -100,37 +99,35 @@ function VideoModal({

Video Settings

- - Start Time (seconds) - - setVideoDetails({ - ...videoDetails, - startTime: Math.max(0, parseInt(e.target.value) || 0) - })} - placeholder="0" - /> - - +
+ + setVideoDetails({ + ...videoDetails, + startTime: Math.max(0, parseInt(e.target.value) || 0) + })} + placeholder="0" + /> +
- - End Time (seconds, optional) - - setVideoDetails({ - ...videoDetails, - endTime: e.target.value ? parseInt(e.target.value) : null - })} - placeholder="Leave empty for full duration" - /> - - +
+ + setVideoDetails({ + ...videoDetails, + endTime: e.target.value ? parseInt(e.target.value) : null + })} + placeholder="Leave empty for full duration" + /> +
@@ -164,24 +161,18 @@ function VideoModal({ ) return ( - - - - Activity Name - - Please provide a name for your video activity - - - - setName(e.target.value)} - type="text" - required - placeholder="Enter activity name..." - /> - - + +
+ + setName(e.target.value)} + type="text" + required + placeholder="Enter activity name..." + /> +
@@ -214,10 +205,11 @@ function VideoModal({
{selectedView === 'file' && (
- - Video File +
+
- +
)} {selectedView === 'youtube' && (
- - YouTube URL - - setYoutubeUrl(e.target.value)} - type="text" - required - placeholder="https://youtube.com/watch?v=..." - /> - - +
+ + setYoutubeUrl(e.target.value)} + type="text" + required + placeholder="https://youtube.com/watch?v=..." + /> +
)}
- - - - {isSubmitting ? ( - - ) : ( - 'Create Activity' - )} - - - - +
+ +
+ ) } diff --git a/apps/web/services/courses/activities.ts b/apps/web/services/courses/activities.ts index e6209ff3..d8bdd017 100644 --- a/apps/web/services/courses/activities.ts +++ b/apps/web/services/courses/activities.ts @@ -75,14 +75,23 @@ export async function createExternalVideoActivity( data.chapter_id = chapter_id data.activity_id = activity.id - // Add video details if provided - data.details = { - startTime: data.startTime || 0, - endTime: data.endTime || null, - autoplay: data.autoplay || false, - muted: data.muted || false + // 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) From b83366b60b8e048200115e2b3dfa3c75f0513c9f Mon Sep 17 00:00:00 2001 From: swve Date: Thu, 24 Apr 2025 19:23:19 +0200 Subject: [PATCH 12/15] feat: enhance CourseThumbnailLanding to display active authors with avatars --- .../Thumbnails/CourseThumbnailLanding.tsx | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/apps/web/components/Objects/Thumbnails/CourseThumbnailLanding.tsx b/apps/web/components/Objects/Thumbnails/CourseThumbnailLanding.tsx index fbfd2403..69c59db0 100644 --- a/apps/web/components/Objects/Thumbnails/CourseThumbnailLanding.tsx +++ b/apps/web/components/Objects/Thumbnails/CourseThumbnailLanding.tsx @@ -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 = ({ 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 = ({ course, orgslug, customLi

{course.description}

-
+
{course.update_date && (
@@ -139,6 +157,38 @@ const CourseThumbnailLanding: React.FC = ({ course, orgslug, customLi
)} + + {displayedAuthors.length > 0 && ( +
+ {displayedAuthors.map((author, index) => ( +
+ +
+ ))} + {hasMoreAuthors && ( +
+
+ +{remainingAuthorsCount} +
+
+ )} +
+ )}
Date: Thu, 24 Apr 2025 19:32:43 +0200 Subject: [PATCH 13/15] feat: update CourseThumbnail to display course update date and active authors with avatars --- .../Objects/Thumbnails/CourseThumbnail.tsx | 83 +++++++++++++++++-- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/apps/web/components/Objects/Thumbnails/CourseThumbnail.tsx b/apps/web/components/Objects/Thumbnails/CourseThumbnail.tsx index 42ebb5f0..4cbebc70 100644 --- a/apps/web/components/Objects/Thumbnails/CourseThumbnail.tsx +++ b/apps/web/components/Objects/Thumbnails/CourseThumbnail.tsx @@ -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 ( -
+
-
-

{course.name}

-

{course.description}

+
+
+

{course.name}

+

{course.description}

+
+ +
+ {course.update_date && ( +
+ + Updated {new Date(course.update_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} + +
+ )} + + {displayedAuthors.length > 0 && ( +
+ {displayedAuthors.map((author, index) => ( +
+ +
+ ))} + {hasMoreAuthors && ( +
+
+ +{remainingAuthorsCount} +
+
+ )} +
+ )} +
+ + + Start Learning +
) From 6a5886cefe25c94065faa1a106e3a7a18dcde874 Mon Sep 17 00:00:00 2001 From: swve Date: Thu, 24 Apr 2025 21:59:32 +0200 Subject: [PATCH 14/15] feat: add file type validation and improve thumbnail upload UI in ThumbnailUpdate component --- .../EditCourseGeneral/ThumbnailUpdate.tsx | 100 +++++++++++------- 1 file changed, 63 insertions(+), 37 deletions(-) diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx index 461508d5..0564a9a1 100644 --- a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx @@ -19,8 +19,24 @@ function ThumbnailUpdate() { const [showUnsplashPicker, setShowUnsplashPicker] = useState(false) const withUnpublishedActivities = course ? course.withUnpublishedActivities : false + const validateFileType = (file: File): boolean => { + const validTypes = ['image/jpeg', 'image/jpg', 'image/png']; + if (!validTypes.includes(file.type)) { + setError('Please upload only PNG or JPG/JPEG images'); + return false; + } + return true; + } + const handleFileChange = async (event: any) => { const file = event.target.files[0] + if (!file) return; + + if (!validateFileType(file)) { + event.target.value = ''; + return; + } + setLocalThumbnail(file) await updateThumbnail(file) } @@ -53,64 +69,74 @@ function ThumbnailUpdate() { } return ( -
-
-
-
- {error && ( -
-
{error}
-
- )} - {localThumbnail ? ( - - ) : ( - - )} +
+
+ {error && ( +
+
{error}
- {isLoading ? ( -
-
- - Uploading -
-
+ )} + +
+ {localThumbnail ? ( + Course thumbnail ) : ( -
+ Course thumbnail + )} + + {!isLoading && ( +
)}
+ + {isLoading && ( +
+
+ + Uploading... +
+
+ )} + +

Supported formats: PNG, JPG/JPEG

+ {showUnsplashPicker && ( Date: Thu, 24 Apr 2025 22:10:37 +0200 Subject: [PATCH 15/15] feat: enhance ThumbnailUpdate component with improved file validation, cleanup of blob URLs, and error handling for Unsplash image selection --- .../EditCourseGeneral/ThumbnailUpdate.tsx | 108 ++++++++++++------ 1 file changed, 75 insertions(+), 33 deletions(-) diff --git a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx index 0564a9a1..cf907a9d 100644 --- a/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx +++ b/apps/web/components/Dashboard/Pages/Course/EditCourseGeneral/ThumbnailUpdate.tsx @@ -5,66 +5,108 @@ 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('') const [showUnsplashPicker, setShowUnsplashPicker] = useState(false) const withUnpublishedActivities = course ? course.withUnpublishedActivities : false - const validateFileType = (file: File): boolean => { - const validTypes = ['image/jpeg', 'image/jpg', 'image/png']; - if (!validTypes.includes(file.type)) { + // 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: any) => { - const file = event.target.files[0] + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; if (!file) return; - if (!validateFileType(file)) { + if (!validateFile(file)) { event.target.value = ''; return; } - setLocalThumbnail(file) - await updateThumbnail(file) + const blobUrl = URL.createObjectURL(file); + setLocalThumbnail({ file, url: blobUrl }); + await updateThumbnail(file); } const handleUnsplashSelect = async (imageUrl: string) => { - setIsLoading(true) - const response = await fetch(imageUrl) - const blob = await response.blob() - const file = new File([blob], 'unsplash_image.jpg', { type: 'image/jpeg' }) - setLocalThumbnail(file) - await updateThumbnail(file) + try { + setIsLoading(true); + const response = await fetch(imageUrl); + const blob = await response.blob(); + + if (!VALID_MIME_TYPES.includes(blob.type as ValidMimeType)) { + throw new Error('Invalid image format from Unsplash'); + } + + const file = new File([blob], `unsplash_${Date.now()}.jpg`, { type: blob.type }); + + if (!validateFile(file)) { + return; + } + + const blobUrl = URL.createObjectURL(file); + setLocalThumbnail({ file, url: blobUrl }); + await updateThumbnail(file); + } catch (err) { + setError('Failed to process Unsplash image'); + setIsLoading(false); + } } const updateThumbnail = async (file: File) => { - setIsLoading(true) - const res = await updateCourseThumbnail( - course.courseStructure.course_uuid, - file, - session.data?.tokens?.access_token - ) - mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`) - // wait for 1 second to show loading animation - await new Promise((r) => setTimeout(r, 1500)) - if (res.success === false) { - setError(res.HTTPmessage) - } else { - setIsLoading(false) - setError('') + setIsLoading(true); + try { + const res = await updateCourseThumbnail( + course.courseStructure.course_uuid, + file, + session.data?.tokens?.access_token + ); + + await mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`); + await new Promise((r) => setTimeout(r, 1500)); + + if (res.success === false) { + setError(res.HTTPmessage); + } else { + setError(''); + } + } catch (err) { + setError('Failed to update thumbnail'); + } finally { + setIsLoading(false); } } @@ -80,7 +122,7 @@ function ThumbnailUpdate() {
{localThumbnail ? (