From 55ae6dd4fe5a0385ebc95d06416e5b46d7e240d3 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Wed, 19 Aug 2026 11:52:58 +0100 Subject: [PATCH] feat(dashboard): improve student course sidebar navigation (#814) * docs(prototypes): add course sidebar navigation HTML prototypes Co-authored-by: Rotimi Best * docs(prototypes): add downloadable zip for course sidebar mockups Co-authored-by: Rotimi Best * feat(dashboard): improve student course sidebar navigation Collapse all sections except the active one for students, show section completion as counts (e.g. 3/4), and replace the footer progress card with prev/next controls plus a thin progress bar. Co-authored-by: Rotimi Best * fix(dashboard): address PR review and remove sidebar prototypes Remove HTML prototype files from the PR. Match content IDs as exact path segments for active-item resolution, and use content-neutral prev/next labels. Co-authored-by: Rotimi Best * fix(dashboard): keep ClassroomIO brand untranslated in powered-by footer Co-authored-by: Rotimi Best * fix(dashboard): restore progress summary in course sidebar footer Co-authored-by: Rotimi Best * fix(dashboard): move progress bar below summary in sidebar footer Co-authored-by: Rotimi Best * feat(course): add mobile student bottom nav with outline sheet Implement prototype 3 mobile navigation for course learners: a fixed bottom bar with progress ring, prev/next controls, and a sheet showing the progress card plus collapsible course outline with scroll-to-active. Extends public-course UI primitives (ring bottom nav, collapsible sidebar sections, mobile sheet progress slot) and wires them into the authenticated course layout for isCourseLearnerView on lesson/exercise routes. Hides duplicate header prev/next and progress popover on mobile. Co-authored-by: Rotimi Best * fix(course): address review feedback and exclude prototypes - Add .coderabbit.yaml and .greptile/config.json to skip prototypes/** - Document prototypes/ as out-of-scope in AGENTS.md and Greptile rules - Render locked mobile outline rows without href navigation targets - Replace nested IconButton in collapsible section trigger with chevron icon - Reset public-course sidebar section collapse on every mobile sheet reopen Co-authored-by: Rotimi Best --------- --- .coderabbit.yaml | 5 + .greptile/config.json | 3 + .greptile/rules.md | 4 + AGENTS.md | 4 + .../ai-assistant/content-ask-ai-bar.svelte | 6 +- .../course/components/course-header.svelte | 17 +- .../mobile/course-mobile-bottom-nav.svelte | 90 ++++++++ .../mobile/course-mobile-outline-sheet.svelte | 63 ++++++ .../mobile/course-mobile-outline-tree.svelte | 209 ++++++++++++++++++ .../course/components/mobile/index.ts | 3 + .../sidebar/course-content-tree.svelte | 201 +++++++++-------- .../sidebar/course-sidebar-footer-nav.svelte | 107 +++++++++ .../components/sidebar/course-sidebar.svelte | 11 +- .../lib/features/course/pages/exercise.svelte | 16 +- .../lib/features/course/pages/lesson.svelte | 13 +- .../lib/features/course/pages/lessons.svelte | 7 +- .../course/utils/content-navigation.ts | 128 +++++++++++ .../course/utils/mobile-bottom-nav.ts | 15 ++ .../src/lib/features/ui/powered-by.svelte | 2 +- .../src/lib/utils/translations/da.json | 7 +- .../src/lib/utils/translations/de.json | 7 +- .../src/lib/utils/translations/en.json | 5 + .../src/lib/utils/translations/es.json | 7 +- .../src/lib/utils/translations/fr.json | 7 +- .../src/lib/utils/translations/hi.json | 7 +- .../src/lib/utils/translations/pl.json | 7 +- .../src/lib/utils/translations/pt.json | 7 +- .../src/lib/utils/translations/ru.json | 7 +- .../src/lib/utils/translations/vi.json | 7 +- .../routes/(app)/courses/[id]/+layout.svelte | 24 +- .../course/[slug]/lesson/+layout.svelte | 2 +- .../src/molecules/public-course/fixtures.ts | 21 ++ .../public-course.stories.svelte | 35 +++ .../custom/public-course/bottom-nav.svelte | 70 ++++-- .../custom/public-course/mobile-sheet.svelte | 42 +++- .../ui/src/custom/public-course/shell.svelte | 15 ++ .../custom/public-course/sidebar-row.svelte | 7 +- .../src/custom/public-course/sidebar.svelte | 162 ++++++++++---- 38 files changed, 1157 insertions(+), 193 deletions(-) create mode 100644 .coderabbit.yaml create mode 100644 .greptile/config.json create mode 100644 apps/dashboard/src/lib/features/course/components/mobile/course-mobile-bottom-nav.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/mobile/course-mobile-outline-sheet.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/mobile/course-mobile-outline-tree.svelte create mode 100644 apps/dashboard/src/lib/features/course/components/mobile/index.ts create mode 100644 apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-footer-nav.svelte create mode 100644 apps/dashboard/src/lib/features/course/utils/content-navigation.ts create mode 100644 apps/dashboard/src/lib/features/course/utils/mobile-bottom-nav.ts diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..11cb3b11 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,5 @@ +# CodeRabbit configuration — https://docs.coderabbit.ai/reference/configuration +reviews: + path_filters: + # Design/exploration HTML mocks — not shipped application code + - '!prototypes/**' diff --git a/.greptile/config.json b/.greptile/config.json new file mode 100644 index 00000000..69011a60 --- /dev/null +++ b/.greptile/config.json @@ -0,0 +1,3 @@ +{ + "ignorePatterns": "prototypes/**\n**/*.lock\n**/dist/**\n**/node_modules/**" +} diff --git a/.greptile/rules.md b/.greptile/rules.md index b1cf2123..44fe6a71 100644 --- a/.greptile/rules.md +++ b/.greptile/rules.md @@ -2,6 +2,10 @@ Repo-specific conventions for code review. See https://www.greptile.com/docs/code-review/custom-standards. +## Out of scope for review + +The `prototypes/` directory contains standalone HTML/CSS design mocks and exploration artifacts. They are **not** part of the shipped application and should not receive production code review feedback (navigation semantics, a11y, idempotency, etc.). Skip these paths entirely. + ## One-time admin/ops scripts (`packages/db/src/scripts/**`) These are manual, operator-invoked, run-once maintenance scripts (backfills, notifications, migrations). They are **not** part of the request/runtime path. diff --git a/AGENTS.md b/AGENTS.md index b4d17282..4858c331 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,10 @@ This document collects implementation rules and workflow conventions for code ch When a task requires factual information (API specifications, context window sizes, library versions, pricing, rate limits, etc.), **look it up** using web search. Do not rely on educated guesses or assumptions from training data. If you're unsure whether something is a guess, look it up anyway. +## Out of scope paths + +The `prototypes/` directory holds standalone HTML/CSS design mocks for exploration. Do not treat prototype files as production code: avoid implementing features there unless explicitly asked, and do not apply production review standards (CodeRabbit/Greptile exclude this path via `.coderabbit.yaml` and `.greptile/config.json`). + ## Translation, Formatting, and Git Workflow - If `apps/dashboard/src/lib/utils/translations/en.json` changes, update the other dashboard locale files before staging or committing. diff --git a/apps/dashboard/src/lib/features/ai-assistant/content-ask-ai-bar.svelte b/apps/dashboard/src/lib/features/ai-assistant/content-ask-ai-bar.svelte index 8940fec3..79abaa4d 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/content-ask-ai-bar.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/content-ask-ai-bar.svelte @@ -13,9 +13,11 @@ interface Props { /** Width classes matching the page content column (e.g. mx-auto w-full max-w-3xl). */ class?: string; + /** Tailwind bottom offset class for the collapsed bar (e.g. bottom-4, bottom-16). */ + bottomClass?: string; } - let { class: contentClass = 'mx-auto w-full max-w-3xl' }: Props = $props(); + let { class: contentClass = 'mx-auto w-full max-w-3xl', bottomClass = 'bottom-4' }: Props = $props(); const pageShellClass = 'mx-auto w-[90%] max-w-4xl px-3 sm:px-4 lg:max-w-5xl'; @@ -135,7 +137,7 @@ {:else}
diff --git a/apps/dashboard/src/lib/features/course/components/course-header.svelte b/apps/dashboard/src/lib/features/course/components/course-header.svelte index 6135d87f..08dd9bee 100644 --- a/apps/dashboard/src/lib/features/course/components/course-header.svelte +++ b/apps/dashboard/src/lib/features/course/components/course-header.svelte @@ -9,7 +9,10 @@ import { Waves } from '@cio/ui/custom/animation'; import { page } from '$app/state'; import { currentOrg, currentOrgDomain } from '$lib/utils/store/org'; - import { isStudentExperience } from '$lib/utils/store/app'; + import { isStudentExperience, isCourseLearnerView } from '$lib/utils/store/app'; + import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte'; + import { getCourseProgress } from '$features/course/utils/content'; + import { isCourseMobileBottomNavVisible } from '$features/course/utils/mobile-bottom-nav'; import SparklesIcon from '@lucide/svelte/icons/sparkles'; import { setupProgressApi } from '$features/setup/api/setup-progress.svelte'; import { courseApi } from '$features/course/api'; @@ -29,6 +32,16 @@ const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC'); const activeNavKey = $derived(getActiveCourseNavKey(page.url.pathname, courseApi.course?.id ?? '')); const isPublished = $derived(courseApi.course?.isPublished ?? false); + const lessonId = $derived(page.params.lessonId as string | undefined); + const exerciseId = $derived(page.params.exerciseId as string | undefined); + const showMobileBottomNav = $derived( + isCourseMobileBottomNavVisible({ + isCourseLearnerView: $isCourseLearnerView, + isMobile: isMobileStore.current, + isLessonOrExercisePage: Boolean(lessonId || exerciseId), + courseProgress: getCourseProgress(courseApi.course) + }) + ); let viewAsStudentOpen = $state(false); let viewCourseSiteUnpublishedOpen = $state(false); @@ -86,7 +99,7 @@ - {#if $isStudentExperience} + {#if $isCourseLearnerView && !showMobileBottomNav} {/if} diff --git a/apps/dashboard/src/lib/features/course/components/mobile/course-mobile-bottom-nav.svelte b/apps/dashboard/src/lib/features/course/components/mobile/course-mobile-bottom-nav.svelte new file mode 100644 index 00000000..b7e8a8c3 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/mobile/course-mobile-bottom-nav.svelte @@ -0,0 +1,90 @@ + + + goToContent(previousItem)} + onNext={() => goToContent(nextItem)} + onOpenSheet={() => (sheetOpen = true)} + openSheetLabel={$t('course.navItems.nav_content')} + prevLabel={$t('course.sidebar.footer_nav.previous')} + nextLabel={$t('course.sidebar.footer_nav.next')} + centerVariant="ring" + progressPercent={courseProgress.percent} +/> + + diff --git a/apps/dashboard/src/lib/features/course/components/mobile/course-mobile-outline-sheet.svelte b/apps/dashboard/src/lib/features/course/components/mobile/course-mobile-outline-sheet.svelte new file mode 100644 index 00000000..aa240a2f --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/mobile/course-mobile-outline-sheet.svelte @@ -0,0 +1,63 @@ + + + + + + + + + + {$t('course.navItems.nav_content')} + + + +
+ +
+ +
+ +
+
+
+
diff --git a/apps/dashboard/src/lib/features/course/components/mobile/course-mobile-outline-tree.svelte b/apps/dashboard/src/lib/features/course/components/mobile/course-mobile-outline-tree.svelte new file mode 100644 index 00000000..b336ae9e --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/mobile/course-mobile-outline-tree.svelte @@ -0,0 +1,209 @@ + + +{#snippet liveSessionDot()} + + + +{/snippet} + +{#snippet contentItemIndicators( + contentItem: (typeof contentData.items)[number], + isContentLocked: boolean, + isLockedForStudent: boolean +)} + {#if contentItem.isComplete} + + + + {/if} + {#if contentItem.type === ContentType.Lesson && contentItem.callUrl} + {@render liveSessionDot()} + {:else if isContentLocked || isLockedForStudent} + + + + {/if} +{/snippet} + +{#snippet contentItemRow(contentItem: (typeof contentData.items)[number])} + {@const isContentLocked = (contentItem.isUnlocked ?? true) === false} + {@const isLockedForStudent = $isCourseLearnerView && (isContentLocked || contentItem.accessible === false)} + {@const isActive = isContentItemInPath(contentItem.id, currentPath)} + {@const rowClass = + 'flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors ' + + (isActive + ? 'ui:bg-sidebar-accent ui:text-sidebar-accent-foreground font-medium' + : 'ui:text-foreground hover:ui:bg-muted') + + (isLockedForStudent ? ' cursor-not-allowed opacity-50' : '')} + + {#if isLockedForStudent} + + + {contentItem.title} +
+ {@render contentItemIndicators(contentItem, isContentLocked, isLockedForStudent)} +
+
+ {:else} + { + if (contentData.grouped) { + const sectionId = getSectionIdForContentItem(courseApi.course, contentItem.id); + if (sectionId) { + expandedSectionIds.clear(); + expandedSectionIds.add(sectionId); + } + } + }} + > + + {contentItem.title} +
+ {@render contentItemIndicators(contentItem, isContentLocked, isLockedForStudent)} +
+
+ {/if} +{/snippet} + + + + diff --git a/apps/dashboard/src/lib/features/course/components/mobile/index.ts b/apps/dashboard/src/lib/features/course/components/mobile/index.ts new file mode 100644 index 00000000..7773d342 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/mobile/index.ts @@ -0,0 +1,3 @@ +export { default as CourseMobileBottomNav } from './course-mobile-bottom-nav.svelte'; +export { default as CourseMobileOutlineSheet } from './course-mobile-outline-sheet.svelte'; +export { default as CourseMobileOutlineTree } from './course-mobile-outline-tree.svelte'; diff --git a/apps/dashboard/src/lib/features/course/components/sidebar/course-content-tree.svelte b/apps/dashboard/src/lib/features/course/components/sidebar/course-content-tree.svelte index e564df87..8de30b59 100644 --- a/apps/dashboard/src/lib/features/course/components/sidebar/course-content-tree.svelte +++ b/apps/dashboard/src/lib/features/course/components/sidebar/course-content-tree.svelte @@ -9,12 +9,19 @@ import { ContentType } from '@cio/utils/constants/content'; import { courseApi } from '$features/course/api'; import { getContentItemsProgress, getContentRoute, getCourseContent } from '$features/course/utils/content'; + import { + formatSectionCompletionLabel, + getActiveSectionId, + getSectionIdForContentItem, + isContentItemInPath + } from '$features/course/utils/content-navigation'; import { CircleCheckIcon } from '$features/ui/icons'; import { t } from '$lib/utils/functions/translations'; import { IconButton } from '@cio/ui/custom/icon-button'; import { RadioIcon } from '@cio/ui/custom/moving-icons'; import CourseContentIcon from '$features/course/components/course-content-icon.svelte'; import { isCourseLearnerView } from '$lib/utils/store/app'; + import { SvelteSet } from 'svelte/reactivity'; interface Props { path: string; @@ -29,6 +36,46 @@ const contentData = $derived(getCourseContent(courseApi.course)); const canOpenContentModal = $derived(Boolean(onOpenContentModal) && !isStudent); + const currentPath = $derived(path || page.url.pathname); + const activeSectionId = $derived(getActiveSectionId(courseApi.course, currentPath)); + + let expandedSectionIds = new SvelteSet(); + let trackedActiveSectionId = $state(null); + + $effect(() => { + if (!isStudent || !contentData.grouped || !activeSectionId) { + return; + } + + if (activeSectionId === trackedActiveSectionId) { + return; + } + + trackedActiveSectionId = activeSectionId; + expandedSectionIds.clear(); + expandedSectionIds.add(activeSectionId); + }); + + function isSectionOpen(sectionId: string): boolean { + if (!isStudent) { + return true; + } + + return expandedSectionIds.has(sectionId); + } + + function handleSectionOpenChange(sectionId: string, open: boolean) { + if (!isStudent) { + return; + } + + if (open) { + expandedSectionIds.add(sectionId); + return; + } + + expandedSectionIds.delete(sectionId); + } {#snippet liveSessionDot()} @@ -41,10 +88,67 @@ {/snippet} +{#snippet contentItemRow(contentItem: (typeof contentData.items)[number])} + {@const isContentLocked = (contentItem.isUnlocked ?? true) === false} + {@const isLockedForStudent = $isCourseLearnerView && (isContentLocked || contentItem.accessible === false)} + + + {#snippet child({ props })} + { + if (isLockedForStudent) { + event.preventDefault(); + } else if (isStudent && contentData.grouped) { + const sectionId = getSectionIdForContentItem(courseApi.course, contentItem.id); + if (sectionId) { + expandedSectionIds.clear(); + expandedSectionIds.add(sectionId); + } + } + }} + {...props} + > + + {contentItem.title} +
+ {#if contentItem.isComplete} + + + + {/if} + {#if contentItem.type === ContentType.Lesson && contentItem.callUrl} + {@render liveSessionDot()} + {:else if isContentLocked || isLockedForStudent} + + + + {/if} +
+
+ {/snippet} +
+
+{/snippet} + {#if contentData.grouped} {#each contentData.sections as section (section.id)} - + {@const sectionProgress = getContentItemsProgress(section.items)} + {@const sectionOpen = isSectionOpen(section.id)} + handleSectionOpenChange(section.id, open)} + class="group/section" + > {#snippet child({ props })} @@ -56,14 +160,14 @@
{#if isStudent} - {@const sectionProgress = getContentItemsProgress(section.items)} {#if sectionProgress.total > 0} - {sectionProgress.percent}% + {formatSectionCompletionLabel(sectionProgress.completed, sectionProgress.total)} {/if} {/if} @@ -75,7 +179,7 @@ onclick={(event) => { event.preventDefault(); event.stopPropagation(); - onOpenContentModal(section.id); + onOpenContentModal?.(section.id); }} > @@ -94,50 +198,7 @@ {#each section.items as contentItem (contentItem.id)} - - - {#snippet child({ props })} - {@const isContentLocked = (contentItem.isUnlocked ?? true) === false} - {@const isLockedForStudent = - $isCourseLearnerView && (isContentLocked || contentItem.accessible === false)} - { - if (isLockedForStudent) { - event.preventDefault(); - } - }} - {...props} - > - - {contentItem.title} -
- {#if contentItem.isComplete} - - - - {/if} - {#if contentItem.type === ContentType.Lesson && contentItem.callUrl} - {@render liveSessionDot()} - {:else if isContentLocked || isLockedForStudent} - - - - {/if} -
-
- {/snippet} -
-
+ {@render contentItemRow(contentItem)} {/each}
@@ -147,47 +208,7 @@ {/each} {:else} {#each contentData.items as contentItem (contentItem.id)} - - - {#snippet child({ props })} - {@const isContentLocked = (contentItem.isUnlocked ?? true) === false} - {@const isLockedForStudent = $isCourseLearnerView && (isContentLocked || contentItem.accessible === false)} - { - if (isLockedForStudent) { - event.preventDefault(); - } - }} - {...props} - > - - {contentItem.title} -
- {#if contentItem.isComplete} - - - - {/if} - {#if contentItem.type === ContentType.Lesson && contentItem.callUrl} - {@render liveSessionDot()} - {:else if isContentLocked || isLockedForStudent} - - - - {/if} -
-
- {/snippet} -
-
+ {@render contentItemRow(contentItem)} {/each} {/if} diff --git a/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-footer-nav.svelte b/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-footer-nav.svelte new file mode 100644 index 00000000..5f740482 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-footer-nav.svelte @@ -0,0 +1,107 @@ + + +
+ + +

+ {$t('course.sidebar.progress.lessons', { + completed: courseProgress.lessonsComplete, + total: courseProgress.lessonsTotal + })}{#if courseProgress.exercisesTotal > 0} + · {$t('course.sidebar.progress.exercises', { + completed: courseProgress.exercisesComplete, + total: courseProgress.exercisesTotal + })}{/if} +

+ +
diff --git a/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar.svelte b/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar.svelte index 4dd2079f..1df1567a 100644 --- a/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar.svelte +++ b/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar.svelte @@ -13,7 +13,7 @@ import { courseApi } from '$features/course/api'; import { getCourseProgress } from '$features/course/utils/content'; import { useSidebar } from '@cio/ui/base/sidebar'; - import CourseProgressCard from '$features/course/components/course-progress-card.svelte'; + import CourseSidebarFooterNav from '$features/course/components/sidebar/course-sidebar-footer-nav.svelte'; import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel'; import { COURSE_SIDEBAR_DEFAULT_WIDTH, COURSE_SIDEBAR_MAX_WIDTH, COURSE_SIDEBAR_MIN_WIDTH } from './constants'; @@ -48,7 +48,7 @@ const attributionCourseSlug = $derived(courseApi.course?.slug ?? null); const courseProgress = $derived(getCourseProgress(courseApi.course)); - const showCourseProgress = $derived( + const showStudentFooterNav = $derived( $isStudentExperience && isCourseReady && sidebar.open && !sidebar.isMobile && courseProgress.total > 0 ); @@ -149,11 +149,8 @@ - {#if showCourseProgress} - + {#if showStudentFooterNav} + {/if}
- {#if $isStudentExperience && courseApi.course?.id && exerciseId} + {#if $isCourseLearnerView && courseApi.course?.id && exerciseId && !showMobileBottomNav} {/if} diff --git a/apps/dashboard/src/lib/features/course/pages/lesson.svelte b/apps/dashboard/src/lib/features/course/pages/lesson.svelte index 4c2a66a1..a30a4636 100644 --- a/apps/dashboard/src/lib/features/course/pages/lesson.svelte +++ b/apps/dashboard/src/lib/features/course/pages/lesson.svelte @@ -17,6 +17,9 @@ import MODES from '$lib/utils/constants/mode'; import { profile } from '$lib/utils/store/user'; import { isCourseLearnerView, isStudentExperience } from '$lib/utils/store/app'; + import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte'; + import { getCourseProgress } from '$features/course/utils/content'; + import { isCourseMobileBottomNavVisible } from '$features/course/utils/mobile-bottom-nav'; import { getStudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar'; import { currentOrg } from '$lib/utils/store/org'; import { snackbar } from '$features/ui/snackbar/store'; @@ -68,6 +71,14 @@ let { courseId, lessonId }: Props = $props(); const mode = $derived($page.url.searchParams.get('mode') === 'edit' ? MODES.edit : MODES.view); + const showMobileBottomNav = $derived( + isCourseMobileBottomNavVisible({ + isCourseLearnerView: $isCourseLearnerView, + isMobile: isMobileStore.current, + isLessonOrExercisePage: true, + courseProgress: getCourseProgress(courseApi.course) + }) + ); let prevModeParam = $state(null); let isDeletingLesson = $state(false); @@ -422,7 +433,7 @@
- {#if mode === MODES.view && $isStudentExperience} + {#if mode === MODES.view && $isCourseLearnerView && !showMobileBottomNav} {/if} diff --git a/apps/dashboard/src/lib/features/course/pages/lessons.svelte b/apps/dashboard/src/lib/features/course/pages/lessons.svelte index f30776de..79ce94bc 100644 --- a/apps/dashboard/src/lib/features/course/pages/lessons.svelte +++ b/apps/dashboard/src/lib/features/course/pages/lessons.svelte @@ -9,6 +9,7 @@ import { courseApi } from '$features/course/api'; import { t } from '$lib/utils/functions/translations'; import { getCourseContent } from '$features/course/utils/content'; + import { getFirstIncompleteNavigableContent } from '$features/course/utils/content-navigation'; import { ContentType } from '@cio/utils/constants/content'; interface Props { @@ -41,15 +42,11 @@ const isCourseLoadedForThisPage = $derived(courseApi.course?.id === courseId); const canResolveNext = $derived(isCourseLoadedForThisPage && navigableContentItems.length > 0 && !hasHandledNext); - function findFirstIncompleteContent() { - return navigableContentItems.find((item) => !item.isComplete && item.isUnlocked === true); - } - $effect(() => { if (!canResolveNext || isFetching || query.get('next') !== 'true') return; hasHandledNext = true; - const incompleteContent = findFirstIncompleteContent(); + const incompleteContent = getFirstIncompleteNavigableContent(courseApi.course); if (incompleteContent) { if (incompleteContent.type === ContentType.Lesson) { goto(`/courses/${courseId}/lessons/${incompleteContent.id}`); diff --git a/apps/dashboard/src/lib/features/course/utils/content-navigation.ts b/apps/dashboard/src/lib/features/course/utils/content-navigation.ts new file mode 100644 index 00000000..06337e67 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/content-navigation.ts @@ -0,0 +1,128 @@ +import type { Course } from './types'; +import { getCourseContent, getOrderedNavigableContent, type ContentItem } from './content'; + +export function isNavigableContentUnlocked(item: ContentItem): boolean { + return (item.isUnlocked ?? true) === true; +} + +export function getFirstIncompleteNavigableContent(course: Course | null): ContentItem | undefined { + return getOrderedNavigableContent(course).find((item) => !item.isComplete && isNavigableContentUnlocked(item)); +} + +export function isContentItemInPath(itemId: string, currentPath: string): boolean { + const pathSegments = currentPath.split('/').filter(Boolean); + return pathSegments.includes(itemId); +} + +export function findActiveNavigableContentIndex(items: ContentItem[], currentPath: string): number { + return items.findIndex((item) => isContentItemInPath(item.id, currentPath)); +} + +export function resolveActiveNavigableContentIndex(course: Course | null, currentPath: string): number { + const items = getOrderedNavigableContent(course); + if (items.length === 0) { + return -1; + } + + const pathIndex = findActiveNavigableContentIndex(items, currentPath); + if (pathIndex >= 0) { + return pathIndex; + } + + const firstIncomplete = getFirstIncompleteNavigableContent(course); + if (firstIncomplete) { + return items.findIndex((item) => item.id === firstIncomplete.id); + } + + return 0; +} + +export function getPreviousNavigableContent(course: Course | null, currentPath: string): ContentItem | null { + const items = getOrderedNavigableContent(course); + const activeIndex = resolveActiveNavigableContentIndex(course, currentPath); + if (activeIndex <= 0) { + return null; + } + + return items[activeIndex - 1] ?? null; +} + +export function getNextIncompleteNavigableContent(course: Course | null, currentPath: string): ContentItem | null { + const items = getOrderedNavigableContent(course); + const activeIndex = resolveActiveNavigableContentIndex(course, currentPath); + if (activeIndex < 0) { + return null; + } + + for (let index = activeIndex + 1; index < items.length; index += 1) { + const item = items[index]; + if (!item.isComplete && isNavigableContentUnlocked(item)) { + return item; + } + } + + return null; +} + +export function getActiveNavigableContent(course: Course | null, currentPath: string): ContentItem | null { + const items = getOrderedNavigableContent(course); + const activeIndex = resolveActiveNavigableContentIndex(course, currentPath); + if (activeIndex < 0) { + return null; + } + + return items[activeIndex] ?? null; +} + +export function getSectionIdForContentItem(course: Course | null, itemId: string | undefined): string | null { + if (!itemId) { + return null; + } + + const content = getCourseContent(course); + if (!content.grouped) { + return null; + } + + for (const section of content.sections) { + if (section.items.some((item) => item.id === itemId)) { + return section.id; + } + } + + return null; +} + +export function getActiveSectionId(course: Course | null, currentPath: string): string | null { + const activeItem = getActiveNavigableContent(course, currentPath); + if (!activeItem) { + return null; + } + + return getSectionIdForContentItem(course, activeItem.id); +} + +export function formatSectionCompletionLabel(completed: number, total: number): string { + return `${completed}/${total}`; +} + +export function collapseExpandedSectionsToActive(activeSectionId: string | null): Set { + if (!activeSectionId) { + return new Set(); + } + + return new Set([activeSectionId]); +} + +export function scrollOutlineToActiveItem(container: HTMLElement | null, itemId: string): void { + if (!container || !itemId) { + return; + } + + requestAnimationFrame(() => { + const activeRow = container.querySelector(`[data-sidebar-content-id="${itemId}"]`); + if (activeRow instanceof HTMLElement) { + activeRow.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + }); +} diff --git a/apps/dashboard/src/lib/features/course/utils/mobile-bottom-nav.ts b/apps/dashboard/src/lib/features/course/utils/mobile-bottom-nav.ts new file mode 100644 index 00000000..619cd560 --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/mobile-bottom-nav.ts @@ -0,0 +1,15 @@ +import type { ContentProgress } from './content'; + +export function isCourseMobileBottomNavVisible(options: { + isCourseLearnerView: boolean; + isMobile: boolean; + isLessonOrExercisePage: boolean; + courseProgress: ContentProgress; +}): boolean { + return ( + options.isCourseLearnerView && + options.isMobile && + options.isLessonOrExercisePage && + options.courseProgress.total > 0 + ); +} diff --git a/apps/dashboard/src/lib/features/ui/powered-by.svelte b/apps/dashboard/src/lib/features/ui/powered-by.svelte index 9e454b85..9e589c20 100644 --- a/apps/dashboard/src/lib/features/ui/powered-by.svelte +++ b/apps/dashboard/src/lib/features/ui/powered-by.svelte @@ -38,7 +38,7 @@ orgSlug={attributionOrgSlug} utmSource={sidebarUtmSource} label={$t('public_course.powered_by.label')} - brand={$t('public_course.powered_by.brand')} + brand="ClassroomIO" compact={showOnlyLogo} align={showOnlyLogo ? 'center' : 'start'} class={className} diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index cd9b332e..9972e442 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -2433,6 +2433,11 @@ "completed": "{completed} af {total} fuldført", "lessons": "{completed} af {total} lektioner", "exercises": "{completed} af {total} øvelser" + }, + "footer_nav": { + "label": "Navigation i kursusindhold", + "previous": "Forrige", + "next": "Næste" } }, "navItems": { @@ -4639,7 +4644,7 @@ }, "powered_by": { "label": "Drevet af", - "brand": "KlasseværelseIO" + "brand": "ClassroomIO" }, "footer_nav": { "previous": "Forrige", diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index aba71dca..8ae637f7 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -2433,6 +2433,11 @@ "completed": "{completed} von {total} abgeschlossen", "lessons": "{completed} von {total} Lektionen", "exercises": "{completed} von {total} Übungen" + }, + "footer_nav": { + "label": "Kursinhalt-Navigation", + "previous": "Zurück", + "next": "Weiter" } }, "navItems": { @@ -4639,7 +4644,7 @@ }, "powered_by": { "label": "Unterstützt von", - "brand": "KlassenzimmerIO" + "brand": "ClassroomIO" }, "footer_nav": { "previous": "Zurück", diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index 34b7419a..fb8aabd8 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -2017,6 +2017,11 @@ "completed": "{completed} of {total} completed", "lessons": "{completed} of {total} lessons", "exercises": "{completed} of {total} exercises" + }, + "footer_nav": { + "label": "Course content navigation", + "previous": "Previous", + "next": "Next" } }, "search": { diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index 8131dda4..f33ee718 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -2433,6 +2433,11 @@ "completed": "{completed} de {total} completados", "lessons": "{completed} de {total} lecciones", "exercises": "{completed} de {total} ejercicios" + }, + "footer_nav": { + "label": "Navegación del contenido del curso", + "previous": "Anterior", + "next": "Siguiente" } }, "navItems": { @@ -4639,7 +4644,7 @@ }, "powered_by": { "label": "Desarrollado por", - "brand": "AulaIO" + "brand": "ClassroomIO" }, "footer_nav": { "previous": "Anterior", diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 389d5747..b7c3b377 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -2433,6 +2433,11 @@ "completed": "{completed} sur {total} terminés", "lessons": "{completed} sur {total} leçons", "exercises": "{completed} sur {total} exercices" + }, + "footer_nav": { + "label": "Navigation du contenu du cours", + "previous": "Précédent", + "next": "Suivant" } }, "navItems": { @@ -4639,7 +4644,7 @@ }, "powered_by": { "label": "Alimenté par", - "brand": "Salle de classeIO" + "brand": "ClassroomIO" }, "footer_nav": { "previous": "Précédent", diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 7d1c2310..0e735956 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -2433,6 +2433,11 @@ "completed": "{total} में से {completed} पूर्ण", "lessons": "{total} में से {completed} पाठ", "exercises": "{total} में से {completed} अभ्यास" + }, + "footer_nav": { + "label": "कोर्स सामग्री नेविगेशन", + "previous": "पिछला", + "next": "अगला" } }, "navItems": { @@ -4639,7 +4644,7 @@ }, "powered_by": { "label": "द्वारा संचालित", - "brand": "क्लासरूमआईओ" + "brand": "ClassroomIO" }, "footer_nav": { "previous": "पिछला", diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 2761740c..38d7988b 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -2433,6 +2433,11 @@ "completed": "Ukończono {completed} z {total}", "lessons": "{completed} z {total} lekcji", "exercises": "{completed} z {total} ćwiczeń" + }, + "footer_nav": { + "label": "Nawigacja po treści kursu", + "previous": "Poprzedni", + "next": "Następny" } }, "navItems": { @@ -4639,7 +4644,7 @@ }, "powered_by": { "label": "Obsługiwane przez", - "brand": "KlasaIO" + "brand": "ClassroomIO" }, "footer_nav": { "previous": "Poprzedni", diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index db19e2ec..4872a066 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -2433,6 +2433,11 @@ "completed": "{completed} de {total} concluídos", "lessons": "{completed} de {total} aulas", "exercises": "{completed} de {total} exercícios" + }, + "footer_nav": { + "label": "Navegação do conteúdo do curso", + "previous": "Anterior", + "next": "Próximo" } }, "navItems": { @@ -4639,7 +4644,7 @@ }, "powered_by": { "label": "Desenvolvido por", - "brand": "Sala de aulaIO" + "brand": "ClassroomIO" }, "footer_nav": { "previous": "Anterior", diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index 3443a081..684c3e66 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -2433,6 +2433,11 @@ "completed": "Завершено {completed} из {total}", "lessons": "{completed} из {total} уроков", "exercises": "{completed} из {total} упражнений" + }, + "footer_nav": { + "label": "Навигация по содержанию курса", + "previous": "Назад", + "next": "Далее" } }, "navItems": { @@ -4639,7 +4644,7 @@ }, "powered_by": { "label": "При поддержке", - "brand": "КлассIO" + "brand": "ClassroomIO" }, "footer_nav": { "previous": "Предыдущий", diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index c8204d9b..45181eca 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -2433,6 +2433,11 @@ "completed": "Đã hoàn thành {completed} trong số {total}", "lessons": "{completed} trong số {total} bài học", "exercises": "{completed} trong số {total} bài tập" + }, + "footer_nav": { + "label": "Điều hướng nội dung khóa học", + "previous": "Trước", + "next": "Tiếp" } }, "navItems": { @@ -4639,7 +4644,7 @@ }, "powered_by": { "label": "Được cung cấp bởi", - "brand": "Lớp họcIO" + "brand": "ClassroomIO" }, "footer_nav": { "previous": "trước đó", diff --git a/apps/dashboard/src/routes/(app)/courses/[id]/+layout.svelte b/apps/dashboard/src/routes/(app)/courses/[id]/+layout.svelte index 189e4487..9b5f3b3e 100644 --- a/apps/dashboard/src/routes/(app)/courses/[id]/+layout.svelte +++ b/apps/dashboard/src/routes/(app)/courses/[id]/+layout.svelte @@ -26,6 +26,10 @@ import { profile } from '$lib/utils/store/user'; import { isOrgAdmin } from '$lib/utils/store/org'; import { isCourseLearnerView } from '$lib/utils/store/app'; + import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte'; + import { CourseMobileBottomNav } from '$features/course/components/mobile'; + import { getCourseProgress } from '$features/course/utils/content'; + import { isCourseMobileBottomNavVisible } from '$features/course/utils/mobile-bottom-nav'; import { t } from '$lib/utils/functions/translations'; import { ContentType } from '@cio/utils/constants/content'; import type { CourseMember } from '$features/course/utils/types'; @@ -108,6 +112,18 @@ sidePanel.activePanelId !== AI_ASSISTANT_PANEL_ID ); + const courseProgress = $derived(getCourseProgress(courseApi.course)); + const showMobileBottomNav = $derived( + isCourseMobileBottomNavVisible({ + isCourseLearnerView: $isCourseLearnerView, + isMobile: isMobileStore.current, + isLessonOrExercisePage, + courseProgress + }) + ); + + const contentAskAiBarBottomClass = $derived(showMobileBottomNav ? 'bottom-16' : 'bottom-4'); + function clampSidebarWidth(width: number) { return Math.min(COURSE_SIDEBAR_MAX_WIDTH, Math.max(COURSE_SIDEBAR_MIN_WIDTH, width)); } @@ -200,7 +216,7 @@ onSidebarWidthChange={handleSidebarWidthChange} /> - + @@ -223,7 +239,11 @@ {@render children?.()} {#if showContentAskAiBar} - + + {/if} + + {#if showMobileBottomNav} + {/if} {/if} diff --git a/apps/dashboard/src/routes/(org-site)/course/[slug]/lesson/+layout.svelte b/apps/dashboard/src/routes/(org-site)/course/[slug]/lesson/+layout.svelte index df97d89b..15631f08 100644 --- a/apps/dashboard/src/routes/(org-site)/course/[slug]/lesson/+layout.svelte +++ b/apps/dashboard/src/routes/(org-site)/course/[slug]/lesson/+layout.svelte @@ -52,7 +52,7 @@ footerNextLabel={$t('public_course.footer_nav.next')} courseSlug={data.tree.course.slug} poweredByLabel={$t('public_course.powered_by.label')} - poweredByBrand={$t('public_course.powered_by.brand')} + poweredByBrand="ClassroomIO" onItemClick={navigateTo} onPrev={() => navigateTo(prevItem)} onNext={() => navigateTo(nextItem)} diff --git a/packages/storybook/src/molecules/public-course/fixtures.ts b/packages/storybook/src/molecules/public-course/fixtures.ts index 46848f00..ff47735b 100644 --- a/packages/storybook/src/molecules/public-course/fixtures.ts +++ b/packages/storybook/src/molecules/public-course/fixtures.ts @@ -93,6 +93,27 @@ export const SIDEBAR_FIXTURE: PublicCourseSidebarSection[] = [ } ]; +export const LONG_SIDEBAR_FIXTURE: PublicCourseSidebarSection[] = Array.from({ length: 10 }, (_, sectionIndex) => { + const sectionNumber = sectionIndex + 1; + + return { + id: `section-${sectionNumber}`, + title: `Module ${sectionNumber}`, + items: Array.from({ length: 4 }, (_, itemIndex) => { + const itemNumber = itemIndex + 1; + const slug = `module-${sectionNumber}-lesson-${itemNumber}`; + + return { + kind: 'lesson' as const, + id: `lesson-${sectionNumber}-${itemNumber}`, + slug, + title: `Lesson ${itemNumber}: Topic ${sectionNumber}.${itemNumber}`, + isUnlocked: true + }; + }) + }; +}); + export const LESSON_FIXTURE: PublicLessonViewData = { kind: 'lesson', title: 'Hallucination & limitations', diff --git a/packages/storybook/src/molecules/public-course/public-course.stories.svelte b/packages/storybook/src/molecules/public-course/public-course.stories.svelte index 8a7a9190..26b0217c 100644 --- a/packages/storybook/src/molecules/public-course/public-course.stories.svelte +++ b/packages/storybook/src/molecules/public-course/public-course.stories.svelte @@ -9,6 +9,7 @@ EXERCISE_FIXTURE, LESSON_FIXTURE, LESSON_LOCKED_FIXTURE, + LONG_SIDEBAR_FIXTURE, ORG_FIXTURE, SIDEBAR_FIXTURE } from './fixtures'; @@ -105,6 +106,40 @@ {/snippet} + + {#snippet template()} +
+ console.log('prev')} + onNext={() => console.log('next')} + onOpenSheet={() => console.log('open sheet')} + /> +
+ {/snippet} +
+ + + {#snippet template()} + {@const activeSlug = 'module-10-lesson-4'} +
+ console.log('navigate', item.slug)} + /> +
+ {/snippet} +
+ {#snippet template()}
diff --git a/packages/ui/src/custom/public-course/bottom-nav.svelte b/packages/ui/src/custom/public-course/bottom-nav.svelte index 7a0c255a..f9930d59 100644 --- a/packages/ui/src/custom/public-course/bottom-nav.svelte +++ b/packages/ui/src/custom/public-course/bottom-nav.svelte @@ -17,6 +17,10 @@ openSheetLabel?: string; prevLabel?: string; nextLabel?: string; + /** Center button style: list icon (public default) or progress ring (student mobile). */ + centerVariant?: 'list' | 'ring'; + /** 0–100 progress for the ring variant. */ + progressPercent?: number; class?: string; } @@ -31,8 +35,22 @@ openSheetLabel = 'Open course outline', prevLabel = 'Previous lesson', nextLabel = 'Next lesson', + centerVariant = 'list', + progressPercent = 0, class: className }: Props = $props(); + + const navButtonClass = + 'ui:inline-flex ui:items-center ui:justify-center ui:rounded-md ui:px-3 ui:py-3 ui:text-foreground ui:transition-[background,transform] ui:duration-100 ui:hover:bg-muted ui:active:scale-[0.94] ui:active:bg-muted ui:disabled:opacity-30 ui:disabled:active:scale-100'; + + const centerButtonClass = + 'ui:flex ui:flex-1 ui:items-center ui:justify-center ui:gap-2 ui:rounded-md ui:px-3 ui:py-2 ui:text-center ui:transition-[background,transform] ui:duration-100 ui:hover:bg-muted ui:active:scale-[0.98] ui:active:bg-muted'; + + const ringCircumference = 94.2; + const ringOffset = $derived( + ringCircumference - (ringCircumference * Math.min(100, Math.max(0, progressPercent))) / 100 + ); + const ringLabel = $derived(`${Math.round(progressPercent)}%`); diff --git a/packages/ui/src/custom/public-course/mobile-sheet.svelte b/packages/ui/src/custom/public-course/mobile-sheet.svelte index fae5a929..75596a45 100644 --- a/packages/ui/src/custom/public-course/mobile-sheet.svelte +++ b/packages/ui/src/custom/public-course/mobile-sheet.svelte @@ -1,4 +1,5 @@ +{#snippet sectionItems(section: PublicCourseSidebarSection)} + {#each section.items as item, index (item.id)} + onItemClick?.(item)} + /> + {/each} +{/snippet} +