feat(dashboard): improve student course sidebar navigation (#814)

* docs(prototypes): add course sidebar navigation HTML prototypes

Co-authored-by: Rotimi Best <[email protected]>

* docs(prototypes): add downloadable zip for course sidebar mockups

Co-authored-by: Rotimi Best <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

* fix(dashboard): keep ClassroomIO brand untranslated in powered-by footer

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): restore progress summary in course sidebar footer

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): move progress bar below summary in sidebar footer

Co-authored-by: Rotimi Best <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------
This commit is contained in:
Rotimi Best
2026-08-19 11:52:58 +01:00
committed by GitHub
co-authored by Rotimi Best
parent bf3a7e7057
commit 55ae6dd4fe
38 changed files with 1157 additions and 193 deletions
+5
View File
@@ -0,0 +1,5 @@
# CodeRabbit configuration — https://docs.coderabbit.ai/reference/configuration
reviews:
path_filters:
# Design/exploration HTML mocks — not shipped application code
- '!prototypes/**'
+3
View File
@@ -0,0 +1,3 @@
{
"ignorePatterns": "prototypes/**\n**/*.lock\n**/dist/**\n**/node_modules/**"
}
+4
View File
@@ -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.
+4
View File
@@ -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.
@@ -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 @@
</div>
{:else}
<div
class="pointer-events-none fixed right-0 bottom-4 left-0 z-40 md:right-[var(--side-panel-width,0px)] md:left-[var(--sidebar-width,0px)]"
class="pointer-events-none fixed right-0 {bottomClass} left-0 z-40 md:right-[var(--side-panel-width,0px)] md:left-[var(--sidebar-width,0px)]"
in:fade={collapseIn}
out:fade={collapseOut}
>
@@ -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 @@
<span class="grow"></span>
{#if $isStudentExperience}
{#if $isCourseLearnerView && !showMobileBottomNav}
<CourseProgressPopover class="md:hidden" />
{/if}
@@ -0,0 +1,90 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { PublicCourseBottomNav } from '@cio/ui/custom/public-course';
import { courseApi } from '$features/course/api';
import { getContentRoute, getCourseProgress } from '$features/course/utils/content';
import {
getActiveNavigableContent,
getNextIncompleteNavigableContent,
getPreviousNavigableContent,
resolveActiveNavigableContentIndex
} from '$features/course/utils/content-navigation';
import { getStudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar';
import { getStudentContentLockTitleKey } from '$features/course/utils/content-lock-utils';
import { ContentType } from '@cio/utils/constants/content';
import { t } from '$lib/utils/functions/translations';
import { isCourseLearnerView } from '$lib/utils/store/app';
import { snackbar } from '$features/ui/snackbar/store';
import type { CourseContentItem } from '$features/course/utils/types';
import CourseMobileOutlineSheet from './course-mobile-outline-sheet.svelte';
interface Props {
courseId: string;
path: string;
}
let { courseId, path }: Props = $props();
let sheetOpen = $state(false);
const courseProgress = $derived(getCourseProgress(courseApi.course));
const activeIndex = $derived(resolveActiveNavigableContentIndex(courseApi.course, path));
const activeItem = $derived(getActiveNavigableContent(courseApi.course, path));
const previousItem = $derived(getPreviousNavigableContent(courseApi.course, path));
const nextItem = $derived(getNextIncompleteNavigableContent(courseApi.course, path));
const positionLabel = $derived(
activeIndex >= 0 && courseProgress.total > 0 ? `${activeIndex + 1} / ${courseProgress.total}` : ''
);
function getNavigableLockReason(target: CourseContentItem | null) {
if (!target) {
return null;
}
const targetType = target.type === ContentType.Lesson ? ContentType.Lesson : ContentType.Exercise;
return getStudentContentLockReason(courseApi.course, target.id, targetType);
}
function goToContent(target: CourseContentItem | null) {
if (!target) {
return;
}
const lockReason = getNavigableLockReason(target);
if ($isCourseLearnerView && lockReason) {
snackbar.error(getStudentContentLockTitleKey(lockReason));
return;
}
const route = getContentRoute(courseId, target);
if (!route) {
return;
}
goto(resolve(route, {}));
}
const previousLockReason = $derived($isCourseLearnerView ? getNavigableLockReason(previousItem) : null);
const nextLockReason = $derived($isCourseLearnerView ? getNavigableLockReason(nextItem) : null);
const isPreviousDisabled = $derived(!previousItem || Boolean(previousLockReason));
const isNextDisabled = $derived(!nextItem || Boolean(nextLockReason));
</script>
<PublicCourseBottomNav
{positionLabel}
sublineLabel={activeItem?.title}
hasPrev={!isPreviousDisabled}
hasNext={!isNextDisabled}
onPrev={() => 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}
/>
<CourseMobileOutlineSheet bind:open={sheetOpen} {path} {courseId} />
@@ -0,0 +1,63 @@
<script lang="ts">
import * as Drawer from '@cio/ui/base/drawer';
import CourseProgressCard from '$features/course/components/course-progress-card.svelte';
import { courseApi } from '$features/course/api';
import { getCourseProgress } from '$features/course/utils/content';
import { scrollOutlineToActiveItem } from '$features/course/utils/content-navigation';
import { t } from '$lib/utils/functions/translations';
import CourseMobileOutlineTree from './course-mobile-outline-tree.svelte';
interface Props {
open: boolean;
onOpenChange?: (next: boolean) => void;
path: string;
courseId: string;
}
let { open = $bindable(false), onOpenChange, path, courseId }: Props = $props();
let scrollContainer = $state<HTMLDivElement | null>(null);
let outlineTree = $state<CourseMobileOutlineTree | null>(null);
const courseProgress = $derived(getCourseProgress(courseApi.course));
function handleOpenChange(next: boolean) {
open = next;
onOpenChange?.(next);
if (!next) {
return;
}
outlineTree?.collapseToActiveSection();
const activeItemId = outlineTree?.getActiveItemId();
if (activeItemId) {
scrollOutlineToActiveItem(scrollContainer, activeItemId);
}
}
</script>
<Drawer.Root {open} onOpenChange={handleOpenChange}>
<Drawer.Portal>
<Drawer.Overlay class="ui:fixed ui:inset-0 ui:z-50 ui:bg-black/40" />
<Drawer.Content
class="ui:fixed ui:inset-x-0 ui:bottom-0 ui:z-50 ui:flex ui:max-h-[85vh] ui:flex-col ui:rounded-t-xl ui:border-t ui:border-border ui:bg-background"
>
<div class="ui:mx-auto ui:mt-2 ui:h-1 ui:w-10 ui:rounded-full ui:bg-muted" aria-hidden="true"></div>
<Drawer.Header class="ui:px-5 ui:pt-3 ui:pb-2">
<Drawer.Title class="ui:text-sm ui:font-semibold ui:text-foreground">
{$t('course.navItems.nav_content')}
</Drawer.Title>
</Drawer.Header>
<div class="shrink-0 px-3 pb-2">
<CourseProgressCard {courseProgress} class="rounded-lg border p-3" />
</div>
<div bind:this={scrollContainer} class="flex-1 overflow-y-auto px-2 pb-[max(env(safe-area-inset-bottom),1rem)]">
<CourseMobileOutlineTree bind:this={outlineTree} {path} {courseId} />
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
@@ -0,0 +1,209 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import LockIcon from '@lucide/svelte/icons/lock';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import * as Collapsible from '@cio/ui/base/collapsible';
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 { 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;
courseId: string;
class?: string;
}
let { path, courseId, class: className = '' }: Props = $props();
const contentData = $derived(getCourseContent(courseApi.course));
const currentPath = $derived(path || page.url.pathname);
const activeSectionId = $derived(getActiveSectionId(courseApi.course, currentPath));
const activeItemId = $derived.by(() => {
const items = contentData.grouped ? contentData.sections.flatMap((section) => section.items) : contentData.items;
return items.find((item) => isContentItemInPath(item.id, currentPath))?.id ?? null;
});
let expandedSectionIds = new SvelteSet<string>();
let trackedActiveSectionId = $state<string | null>(null);
$effect(() => {
if (!contentData.grouped || !activeSectionId) {
return;
}
if (activeSectionId === trackedActiveSectionId) {
return;
}
trackedActiveSectionId = activeSectionId;
expandedSectionIds.clear();
expandedSectionIds.add(activeSectionId);
});
function isSectionOpen(sectionId: string): boolean {
return expandedSectionIds.has(sectionId);
}
function handleSectionOpenChange(sectionId: string, open: boolean) {
if (open) {
expandedSectionIds.add(sectionId);
return;
}
expandedSectionIds.delete(sectionId);
}
export function collapseToActiveSection() {
if (!activeSectionId) {
return;
}
expandedSectionIds.clear();
expandedSectionIds.add(activeSectionId);
trackedActiveSectionId = activeSectionId;
}
export function getActiveItemId() {
return activeItemId;
}
</script>
{#snippet liveSessionDot()}
<span
class="shrink-0"
title={$t('course.navItem.lessons.session.live_indicator')}
aria-label={$t('course.navItem.lessons.session.live_indicator')}
>
<RadioIcon size={14} color="#ef4444" />
</span>
{/snippet}
{#snippet contentItemIndicators(
contentItem: (typeof contentData.items)[number],
isContentLocked: boolean,
isLockedForStudent: boolean
)}
{#if contentItem.isComplete}
<span class="shrink-0">
<CircleCheckIcon size={16} filled />
</span>
{/if}
{#if contentItem.type === ContentType.Lesson && contentItem.callUrl}
{@render liveSessionDot()}
{:else if isContentLocked || isLockedForStudent}
<span
class="shrink-0"
title={$t('course.navItem.lessons.add_lesson.lock')}
aria-label={$t('course.navItem.lessons.add_lesson.lock')}
>
<LockIcon size={12} />
</span>
{/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}
<span title={contentItem.title} data-sidebar-content-id={contentItem.id} class={rowClass} aria-disabled="true">
<CourseContentIcon type={contentItem.type} size={14} />
<span class="min-w-0 flex-1 truncate">{contentItem.title}</span>
<div class="ml-auto flex items-center gap-1">
{@render contentItemIndicators(contentItem, isContentLocked, isLockedForStudent)}
</div>
</span>
{:else}
<a
href={resolve(getContentRoute(courseId, contentItem), {})}
title={contentItem.title}
data-sidebar-content-id={contentItem.id}
class={rowClass}
onclick={() => {
if (contentData.grouped) {
const sectionId = getSectionIdForContentItem(courseApi.course, contentItem.id);
if (sectionId) {
expandedSectionIds.clear();
expandedSectionIds.add(sectionId);
}
}
}}
>
<CourseContentIcon type={contentItem.type} size={14} />
<span class="min-w-0 flex-1 truncate">{contentItem.title}</span>
<div class="ml-auto flex items-center gap-1">
{@render contentItemIndicators(contentItem, isContentLocked, isLockedForStudent)}
</div>
</a>
{/if}
{/snippet}
<nav class="flex flex-col gap-1 {className}" aria-label={$t('course.navItems.nav_content')}>
{#if contentData.grouped}
{#each contentData.sections as section (section.id)}
{@const sectionProgress = getContentItemsProgress(section.items)}
{@const sectionOpen = isSectionOpen(section.id)}
<Collapsible.Root
open={sectionOpen}
onOpenChange={(open) => handleSectionOpenChange(section.id, open)}
class="group/section"
>
<Collapsible.Trigger class="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm font-medium">
<CourseContentIcon type={ContentType.Section} size={12} />
<span class="min-w-0 flex-1 truncate">{section.title}</span>
<div class="ml-auto flex items-center gap-1">
{#if sectionProgress.total > 0}
<span
class="shrink-0 text-[11px] font-medium tabular-nums {sectionProgress.completed ===
sectionProgress.total
? 'ui:text-green-600'
: 'ui:text-muted-foreground'}"
>
{formatSectionCompletionLabel(sectionProgress.completed, sectionProgress.total)}
</span>
{/if}
<ChevronRightIcon
class="text-muted-foreground size-3.5 shrink-0 transition-transform duration-200 group-data-[state=open]/section:rotate-90"
/>
</div>
</Collapsible.Trigger>
<Collapsible.Content class="ml-2 flex flex-col gap-0.5 border-l pl-2">
{#each section.items as contentItem (contentItem.id)}
{@render contentItemRow(contentItem)}
{/each}
</Collapsible.Content>
</Collapsible.Root>
{/each}
{:else}
{#each contentData.items as contentItem (contentItem.id)}
{@render contentItemRow(contentItem)}
{/each}
{/if}
</nav>
<style>
a {
text-decoration: none;
}
</style>
@@ -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';
@@ -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<string>();
let trackedActiveSectionId = $state<string | null>(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);
}
</script>
{#snippet liveSessionDot()}
@@ -41,10 +88,67 @@
</span>
{/snippet}
{#snippet contentItemRow(contentItem: (typeof contentData.items)[number])}
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
{@const isLockedForStudent = $isCourseLearnerView && (isContentLocked || contentItem.accessible === false)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton isActive={isContentItemInPath(contentItem.id, currentPath)}>
{#snippet child({ props })}
<a
href={resolve(getContentRoute(id, contentItem), {})}
aria-disabled={isLockedForStudent}
title={contentItem.title}
data-sidebar-content-id={contentItem.id}
class="flex w-full items-center gap-2 {isLockedForStudent ? 'cursor-not-allowed opacity-50' : ''}"
onclick={(event) => {
if (isLockedForStudent) {
event.preventDefault();
} else if (isStudent && contentData.grouped) {
const sectionId = getSectionIdForContentItem(courseApi.course, contentItem.id);
if (sectionId) {
expandedSectionIds.clear();
expandedSectionIds.add(sectionId);
}
}
}}
{...props}
>
<CourseContentIcon type={contentItem.type} size={14} />
<span class="flex-1 truncate">{contentItem.title}</span>
<div class="ml-auto flex items-center gap-1">
{#if contentItem.isComplete}
<span class="shrink-0">
<CircleCheckIcon size={16} filled />
</span>
{/if}
{#if contentItem.type === ContentType.Lesson && contentItem.callUrl}
{@render liveSessionDot()}
{:else if isContentLocked || isLockedForStudent}
<span
class="shrink-0"
title={$t('course.navItem.lessons.add_lesson.lock')}
aria-label={$t('course.navItem.lessons.add_lesson.lock')}
>
<LockIcon size={12} />
</span>
{/if}
</div>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{/snippet}
<Sidebar.MenuSub class={className}>
{#if contentData.grouped}
{#each contentData.sections as section (section.id)}
<Collapsible.Root open={true} class="group/section">
{@const sectionProgress = getContentItemsProgress(section.items)}
{@const sectionOpen = isSectionOpen(section.id)}
<Collapsible.Root
open={sectionOpen}
onOpenChange={(open) => handleSectionOpenChange(section.id, open)}
class="group/section"
>
{#snippet child({ props })}
<Sidebar.MenuSubItem {...props}>
<Collapsible.Trigger>
@@ -56,14 +160,14 @@
<div class="ml-auto flex items-center gap-1">
{#if isStudent}
{@const sectionProgress = getContentItemsProgress(section.items)}
{#if sectionProgress.total > 0}
<span
class="shrink-0 text-[11px] font-medium tabular-nums {sectionProgress.percent === 100
class="shrink-0 text-[11px] font-medium tabular-nums {sectionProgress.completed ===
sectionProgress.total
? 'ui:text-green-600'
: 'ui:text-muted-foreground'}"
>
{sectionProgress.percent}%
{formatSectionCompletionLabel(sectionProgress.completed, sectionProgress.total)}
</span>
{/if}
{/if}
@@ -75,7 +179,7 @@
onclick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenContentModal(section.id);
onOpenContentModal?.(section.id);
}}
>
<Plus />
@@ -94,50 +198,7 @@
<Collapsible.Content>
<Sidebar.MenuSub class="ml-2">
{#each section.items as contentItem (contentItem.id)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
{#snippet child({ props })}
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
{@const isLockedForStudent =
$isCourseLearnerView && (isContentLocked || contentItem.accessible === false)}
<a
href={resolve(getContentRoute(id, contentItem), {})}
aria-disabled={isLockedForStudent}
title={contentItem.title}
class="flex w-full items-center gap-2 {isLockedForStudent
? 'cursor-not-allowed opacity-50'
: ''}"
onclick={(event) => {
if (isLockedForStudent) {
event.preventDefault();
}
}}
{...props}
>
<CourseContentIcon type={contentItem.type} size={14} />
<span class="flex-1 truncate">{contentItem.title}</span>
<div class="ml-auto flex items-center gap-1">
{#if contentItem.isComplete}
<span class="shrink-0">
<CircleCheckIcon size={16} filled />
</span>
{/if}
{#if contentItem.type === ContentType.Lesson && contentItem.callUrl}
{@render liveSessionDot()}
{:else if isContentLocked || isLockedForStudent}
<span
class="shrink-0"
title={$t('course.navItem.lessons.add_lesson.lock')}
aria-label={$t('course.navItem.lessons.add_lesson.lock')}
>
<LockIcon size={12} />
</span>
{/if}
</div>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{@render contentItemRow(contentItem)}
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
@@ -147,47 +208,7 @@
{/each}
{:else}
{#each contentData.items as contentItem (contentItem.id)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
{#snippet child({ props })}
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
{@const isLockedForStudent = $isCourseLearnerView && (isContentLocked || contentItem.accessible === false)}
<a
href={resolve(getContentRoute(id, contentItem), {})}
aria-disabled={isLockedForStudent}
title={contentItem.title}
class="flex w-full items-center gap-2 {isLockedForStudent ? 'cursor-not-allowed opacity-50' : ''}"
onclick={(event) => {
if (isLockedForStudent) {
event.preventDefault();
}
}}
{...props}
>
<CourseContentIcon type={contentItem.type} size={14} />
<span class="flex-1 truncate">{contentItem.title}</span>
<div class="ml-auto flex items-center gap-1">
{#if contentItem.isComplete}
<span class="shrink-0">
<CircleCheckIcon size={16} filled />
</span>
{/if}
{#if contentItem.type === ContentType.Lesson && contentItem.callUrl}
{@render liveSessionDot()}
{:else if isContentLocked || isLockedForStudent}
<span
class="shrink-0"
title={$t('course.navItem.lessons.add_lesson.lock')}
aria-label={$t('course.navItem.lessons.add_lesson.lock')}
>
<LockIcon size={12} />
</span>
{/if}
</div>
</a>
{/snippet}
</Sidebar.MenuSubButton>
</Sidebar.MenuSubItem>
{@render contentItemRow(contentItem)}
{/each}
{/if}
</Sidebar.MenuSub>
@@ -0,0 +1,107 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { Button } from '@cio/ui/base/button';
import { Progress } from '@cio/ui/base/progress';
import { courseApi } from '$features/course/api';
import { getContentRoute, getCourseProgress } from '$features/course/utils/content';
import {
getNextIncompleteNavigableContent,
getPreviousNavigableContent
} from '$features/course/utils/content-navigation';
import { getStudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar';
import { getStudentContentLockTitleKey } from '$features/course/utils/content-lock-utils';
import { ContentType } from '@cio/utils/constants/content';
import { t } from '$lib/utils/functions/translations';
import { isCourseLearnerView } from '$lib/utils/store/app';
import { snackbar } from '$features/ui/snackbar/store';
import type { CourseContentItem } from '$features/course/utils/types';
interface Props {
courseId: string;
path: string;
class?: string;
}
let { courseId, path, class: className = '' }: Props = $props();
const courseProgress = $derived(getCourseProgress(courseApi.course));
const previousItem = $derived(getPreviousNavigableContent(courseApi.course, path));
const nextItem = $derived(getNextIncompleteNavigableContent(courseApi.course, path));
function getNavigableLockReason(target: CourseContentItem | null) {
if (!target) {
return null;
}
const targetType = target.type === ContentType.Lesson ? ContentType.Lesson : ContentType.Exercise;
return getStudentContentLockReason(courseApi.course, target.id, targetType);
}
function goToContent(target: CourseContentItem | null) {
if (!target) {
return;
}
const lockReason = getNavigableLockReason(target);
if ($isCourseLearnerView && lockReason) {
snackbar.error(getStudentContentLockTitleKey(lockReason));
return;
}
const route = getContentRoute(courseId, target);
if (!route) {
return;
}
goto(resolve(route, {}));
}
const previousLockReason = $derived($isCourseLearnerView ? getNavigableLockReason(previousItem) : null);
const nextLockReason = $derived($isCourseLearnerView ? getNavigableLockReason(nextItem) : null);
const isPreviousDisabled = $derived(!previousItem || Boolean(previousLockReason));
const isNextDisabled = $derived(!nextItem || Boolean(nextLockReason));
</script>
<div class={className}>
<nav class="flex gap-2 px-3 py-2.5" aria-label={$t('course.sidebar.footer_nav.label')}>
<Button
size="sm"
variant="outline"
class="flex-1"
onclick={() => goToContent(previousItem)}
disabled={isPreviousDisabled}
title={previousLockReason ? $t(getStudentContentLockTitleKey(previousLockReason)) : undefined}
>
<ChevronLeftIcon size={16} />
{$t('course.sidebar.footer_nav.previous')}
</Button>
<Button
size="sm"
variant="default"
class="flex-1"
onclick={() => goToContent(nextItem)}
disabled={isNextDisabled}
title={nextLockReason ? $t(getStudentContentLockTitleKey(nextLockReason)) : undefined}
>
{$t('course.sidebar.footer_nav.next')}
<ChevronRightIcon size={16} />
</Button>
</nav>
<p class="ui:text-muted-foreground truncate px-3 pb-2 text-[11px]">
{$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}
</p>
<Progress value={courseProgress.percent} max={100} class="ui:h-[3px] ui:rounded-none" aria-hidden="true" />
</div>
@@ -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 @@
<Sidebar.Rail onclick={handleRailClick} onpointerdown={handleRailPointerDown} />
<Sidebar.Footer>
{#if showCourseProgress}
<CourseProgressCard
progress={courseProgress}
class="ui:border-sidebar-border ui:bg-sidebar-accent/50 rounded-lg border p-3"
/>
{#if showStudentFooterNav}
<CourseSidebarFooterNav courseId={id} path={currentPath} class="ui:border-sidebar-border border-t" />
{/if}
<PoweredBy
variant="sidebar"
@@ -30,7 +30,10 @@
import { ZExerciseUpdate } from '@cio/utils/validation/exercise';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { transformQuestionsToApiFormat } from '$features/course/components/exercise/functions';
import { isOrgStudent, isStudentExperience } from '$lib/utils/store/app';
import { isOrgStudent, 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 { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { exerciseApi } from '$features/course/api';
@@ -89,6 +92,15 @@
mySubmissions = []
}: Props = $props();
const showMobileBottomNav = $derived(
isCourseMobileBottomNavVisible({
isCourseLearnerView: $isCourseLearnerView,
isMobile: isMobileStore.current,
isLessonOrExercisePage: Boolean(exerciseId),
courseProgress: getCourseProgress(courseApi.course)
})
);
type ExerciseTab = 'questions' | 'settings' | 'submissions';
function normalizeExerciseTab(tabParam: string | null): ExerciseTab {
@@ -626,7 +638,7 @@
</Page.HeaderContent>
<Page.Action>
<div class="flex items-center gap-2">
{#if $isStudentExperience && courseApi.course?.id && exerciseId}
{#if $isCourseLearnerView && courseApi.course?.id && exerciseId && !showMobileBottomNav}
<ContentNavigationActions courseId={courseApi.course.id} {exerciseId} />
{/if}
@@ -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<string | null>(null);
let isDeletingLesson = $state(false);
@@ -422,7 +433,7 @@
</Page.HeaderContent>
<Page.Action>
<div class="flex items-center gap-2">
{#if mode === MODES.view && $isStudentExperience}
{#if mode === MODES.view && $isCourseLearnerView && !showMobileBottomNav}
<ContentNavigationActions {lessonId} {courseId} />
{/if}
@@ -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}`);
@@ -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<string> {
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' });
}
});
}
@@ -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
);
}
@@ -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}
@@ -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",
@@ -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",
@@ -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": {
@@ -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",
@@ -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",
@@ -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": "पिछला",
@@ -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",
@@ -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",
@@ -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": "Предыдущий",
@@ -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 đó",
@@ -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}
/>
<Sidebar.Inset class="ui:min-w-0 ui:flex-1">
<Sidebar.Inset class="ui:min-w-0 ui:flex-1 {showMobileBottomNav ? 'pb-24' : ''}">
<CourseHeader />
<ContentCreateModal />
<CourseCompletionModal />
@@ -223,7 +239,11 @@
{@render children?.()}
{#if showContentAskAiBar}
<ContentAskAiBar class={contentAskAiWidthClass} />
<ContentAskAiBar class={contentAskAiWidthClass} bottomClass={contentAskAiBarBottomClass} />
{/if}
{#if showMobileBottomNav}
<CourseMobileBottomNav courseId={data.courseId} {path} />
{/if}
{/if}
</Sidebar.Inset>
@@ -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)}
@@ -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',
@@ -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}
</Story>
<Story name="Bottom nav · ring variant">
{#snippet template()}
<div class="ui:relative ui:min-h-[200px] ui:bg-background">
<PublicCourse.PublicCourseBottomNav
positionLabel="3 / 12"
sublineLabel="Hallucination & limitations"
hasPrev
hasNext
centerVariant="ring"
progressPercent={42}
onPrev={() => console.log('prev')}
onNext={() => console.log('next')}
onOpenSheet={() => console.log('open sheet')}
/>
</div>
{/snippet}
</Story>
<Story name="Mobile sheet · collapse + long course">
{#snippet template()}
{@const activeSlug = 'module-10-lesson-4'}
<div class="ui:mx-auto ui:max-w-sm ui:border ui:border-border">
<PublicCourse.PublicCourseMobileSheet
open
sections={LONG_SIDEBAR_FIXTURE}
{activeSlug}
collapseToSectionId="section-10"
title="Course outline"
onItemClick={(item: PublicCourseSidebarItem) => console.log('navigate', item.slug)}
/>
</div>
{/snippet}
</Story>
<Story name="Callout · inline + full variants">
{#snippet template()}
<div class="ui:mx-auto ui:max-w-2xl ui:space-y-8 ui:p-8">
@@ -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)}%`);
</script>
<nav
@@ -42,23 +60,39 @@
)}
aria-label="Lesson navigation"
>
<button
type="button"
class="ui:inline-flex ui:items-center ui:justify-center ui:rounded-md ui:px-3 ui:py-3 ui:text-foreground ui:transition-colors ui:hover:bg-muted ui:disabled:opacity-30"
aria-label={prevLabel}
disabled={!hasPrev}
onclick={onPrev}
>
<button type="button" class={navButtonClass} aria-label={prevLabel} disabled={!hasPrev} onclick={onPrev}>
<ChevronLeftIcon class="ui:size-5" />
</button>
<button
type="button"
class="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-colors ui:hover:bg-muted"
aria-label={openSheetLabel}
onclick={onOpenSheet}
>
<ListIcon class="ui:size-4 ui:text-primary" />
<button type="button" class={centerButtonClass} aria-label={openSheetLabel} onclick={onOpenSheet}>
{#if centerVariant === 'ring'}
<svg class="ui:size-7 ui:shrink-0" viewBox="0 0 36 36" aria-hidden="true">
<circle cx="18" cy="18" r="15" fill="none" class="ui:stroke-border" stroke-width="3"></circle>
<circle
cx="18"
cy="18"
r="15"
fill="none"
class="ui:stroke-primary"
stroke-width="3"
stroke-linecap="round"
stroke-dasharray={ringCircumference}
stroke-dashoffset={ringOffset}
transform="rotate(-90 18 18)"
></circle>
<text
x="18"
y="19"
text-anchor="middle"
dominant-baseline="middle"
class="ui:fill-primary ui:text-[9px] ui:font-bold"
>
{ringLabel}
</text>
</svg>
{:else}
<ListIcon class="ui:size-4 ui:text-primary" />
{/if}
<span class="ui:flex ui:min-w-0 ui:flex-col ui:items-start ui:text-left ui:leading-tight">
<span class="ui:text-[0.7rem] ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
{positionLabel}
@@ -69,13 +103,7 @@
</span>
</button>
<button
type="button"
class="ui:inline-flex ui:items-center ui:justify-center ui:rounded-md ui:px-3 ui:py-3 ui:text-foreground ui:transition-colors ui:hover:bg-muted ui:disabled:opacity-30"
aria-label={nextLabel}
disabled={!hasNext}
onclick={onNext}
>
<button type="button" class={navButtonClass} aria-label={nextLabel} disabled={!hasNext} onclick={onNext}>
<ChevronRightIcon class="ui:size-5" />
</button>
</nav>
@@ -1,4 +1,5 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as Drawer from '../../base/drawer';
import Sidebar from './sidebar.svelte';
import type { PublicCourseSidebarItem, PublicCourseSidebarSection } from './types';
@@ -11,7 +12,10 @@
onItemClick?: (item: PublicCourseSidebarItem) => void;
hrefFor?: (item: PublicCourseSidebarItem) => string;
title?: string;
/** Forwarded to Sidebar's powered-by footer. */
/** Optional progress card rendered between header and scrollable outline. */
progress?: Snippet;
/** When set, collapses all sections except this one when the sheet opens. */
collapseToSectionId?: string | null;
showPoweredBy?: boolean;
courseSlug?: string | null;
orgSlug?: string | null;
@@ -27,6 +31,8 @@
onItemClick,
hrefFor,
title = 'Course outline',
progress,
collapseToSectionId = null,
showPoweredBy = true,
courseSlug = null,
orgSlug = null,
@@ -34,9 +40,30 @@
poweredByBrand = 'ClassroomIO'
}: Props = $props();
let scrollContainer = $state<HTMLDivElement | null>(null);
let collapseResetKey = $state(0);
function scrollToActiveItem() {
if (!scrollContainer || !activeSlug) {
return;
}
requestAnimationFrame(() => {
const activeRow = scrollContainer?.querySelector(`[data-item-slug="${activeSlug}"]`);
if (activeRow instanceof HTMLElement) {
activeRow.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
}
function handleOpenChange(next: boolean) {
open = next;
onOpenChange?.(next);
if (next) {
collapseResetKey += 1;
scrollToActiveItem();
}
}
function handleItemClick(item: PublicCourseSidebarItem) {
@@ -55,12 +82,23 @@
<Drawer.Header class="ui:px-5 ui:pt-3 ui:pb-2">
<Drawer.Title class="ui:text-sm ui:font-semibold ui:text-foreground">{title}</Drawer.Title>
</Drawer.Header>
<div class="ui:flex-1 ui:overflow-y-auto ui:px-2 ui:pb-[max(env(safe-area-inset-bottom),1rem)]">
{#if progress}
<div class="ui:shrink-0 ui:px-3 ui:pb-2">
{@render progress()}
</div>
{/if}
<div
bind:this={scrollContainer}
class="ui:flex-1 ui:overflow-y-auto ui:px-2 ui:pb-[max(env(safe-area-inset-bottom),1rem)]"
>
<Sidebar
{sections}
{activeSlug}
onItemClick={handleItemClick}
{hrefFor}
collapsibleSections={Boolean(collapseToSectionId)}
{collapseToSectionId}
{collapseResetKey}
{showPoweredBy}
{courseSlug}
{orgSlug}
@@ -99,6 +99,20 @@
activeFlatIndex !== null && totalItems > 0 ? `${activeFlatIndex + 1} / ${totalItems}` : ''
);
const activeSectionId = $derived.by(() => {
if (!activeSlug) {
return null;
}
for (const section of sections) {
if (section.items.some((item) => item.slug === activeSlug)) {
return section.id;
}
}
return null;
});
const orgInitial = $derived((org?.name?.trim()?.charAt(0) ?? 'C').toUpperCase());
/** Left/right arrow keys navigate between items when focus is not in an editable element. */
@@ -237,6 +251,7 @@
orgSlug={org?.siteName ?? org?.name ?? null}
{poweredByLabel}
{poweredByBrand}
collapseToSectionId={activeSectionId}
onItemClick={(item) => {
if (!hrefFor) {
onItemClick?.(item);
@@ -10,13 +10,15 @@
active?: boolean;
/** Locked rows show a lock icon and a muted treatment. */
locked?: boolean;
/** Slug used for scroll-into-view targeting in mobile sheets. */
itemSlug?: string;
/** Link href for the row. If omitted, the row renders as a button (story/testing). */
href?: string;
onClick?: () => void;
class?: string;
}
let { number, title, active = false, locked = false, href, onClick, class: className }: Props = $props();
let { number, title, active = false, locked = false, itemSlug, href, onClick, class: className }: Props = $props();
const rowClass = $derived(
cn(
@@ -60,7 +62,7 @@
{/snippet}
{#if href}
<a {href} class={rowClass} aria-current={active ? 'page' : undefined}>
<a {href} class={rowClass} aria-current={active ? 'page' : undefined} data-item-slug={itemSlug}>
{@render body()}
</a>
{:else}
@@ -69,6 +71,7 @@
class={rowClass}
aria-current={active ? 'page' : undefined}
aria-disabled={locked || undefined}
data-item-slug={itemSlug}
onclick={onClick}
>
{@render body()}
@@ -1,5 +1,8 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { mergeProps } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import * as Collapsible from '../../base/collapsible';
import * as Tooltip from '../../base/tooltip';
import { cn } from '../../tools';
import PoweredBy from './powered-by.svelte';
@@ -7,24 +10,20 @@
import type { PublicCourseSidebarItem, PublicCourseSidebarSection } from './types';
interface Props {
// Sections
sections: PublicCourseSidebarSection[];
/** Slug of the active item. Matches against `item.slug`. */
activeSlug?: string | null;
/** Receives the target item on click; components using `hrefFor` should also provide this. */
onItemClick?: (item: PublicCourseSidebarItem) => void;
/** Builds the `href` for a given item. Omit to render rows as buttons. */
hrefFor?: (item: PublicCourseSidebarItem) => string;
/**
* Show the "Powered by ClassroomIO" footer pinned to the bottom of the sidebar.
* Defaults to `true` when paired with `courseSlug` / `orgSlug`.
*/
/** When true, sections collapse/expand; only `collapseToSectionId` stays open on reset. */
collapsibleSections?: boolean;
/** When set, collapses all sections except this one (e.g. on mobile sheet open). */
collapseToSectionId?: string | null;
/** Increment to force collapse reset even when `collapseToSectionId` is unchanged. */
collapseResetKey?: number;
showPoweredBy?: boolean;
/** Course slug used in the powered-by attribution link. */
courseSlug?: string | null;
/** Org slug used in the powered-by attribution link. */
orgSlug?: string | null;
/** Localized labels for the powered-by footer. */
poweredByLabel?: string;
poweredByBrand?: string;
class?: string;
@@ -35,6 +34,9 @@
activeSlug = null,
onItemClick,
hrefFor,
collapsibleSections = false,
collapseToSectionId = null,
collapseResetKey = 0,
showPoweredBy = true,
courseSlug = null,
orgSlug = null,
@@ -42,49 +44,117 @@
poweredByBrand = 'ClassroomIO',
class: className
}: Props = $props();
let expandedSectionIds = $state<Set<string>>(new Set());
let trackedCollapseSignature = $state<string | null>(null);
$effect(() => {
if (!collapsibleSections || !collapseToSectionId) {
return;
}
const collapseSignature = `${collapseToSectionId}:${collapseResetKey}`;
if (collapseSignature === trackedCollapseSignature) {
return;
}
trackedCollapseSignature = collapseSignature;
expandedSectionIds = new Set([collapseToSectionId]);
});
function isSectionOpen(sectionId: string): boolean {
if (!collapsibleSections) {
return true;
}
return expandedSectionIds.has(sectionId);
}
function handleSectionOpenChange(sectionId: string, open: boolean) {
if (!collapsibleSections) {
return;
}
const next = new Set(expandedSectionIds);
if (open) {
next.add(sectionId);
} else {
next.delete(sectionId);
}
expandedSectionIds = next;
}
</script>
{#snippet sectionItems(section: PublicCourseSidebarSection)}
{#each section.items as item, index (item.id)}
<SidebarRow
number={index + 1}
title={item.title}
active={item.slug === activeSlug}
locked={!item.isUnlocked}
itemSlug={item.slug}
href={hrefFor?.(item)}
onClick={() => onItemClick?.(item)}
/>
{/each}
{/snippet}
<div class={cn('ui:flex ui:h-full ui:flex-col ui:bg-sidebar', className)}>
<nav class="ui:flex ui:flex-1 ui:flex-col ui:gap-6 ui:py-6 ui:px-2" aria-label="Course outline">
<Tooltip.Provider delayDuration={300}>
{#each sections as section (section.id)}
<div class="ui:flex ui:min-w-0 ui:flex-col ui:gap-2">
<h2 class="ui:m-0 ui:min-w-0 ui:w-full">
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<span
{...mergeProps(
{
class: cn(
'ui:block ui:w-full ui:min-w-0 ui:truncate ui:px-3 ui:font-medium ui:uppercase ui:text-[11px] ui:text-muted-foreground ui:transition-colors'
)
},
props
)}
>
{section.title}
</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={6} class="ui:max-w-xs">
{section.title}
</Tooltip.Content>
</Tooltip.Root>
</h2>
<div class="ui:flex ui:flex-col">
{#each section.items as item, index (item.id)}
<SidebarRow
number={index + 1}
title={item.title}
active={item.slug === activeSlug}
locked={!item.isUnlocked}
href={hrefFor?.(item)}
onClick={() => onItemClick?.(item)}
/>
{/each}
{#if collapsibleSections}
<Collapsible.Root
open={isSectionOpen(section.id)}
onOpenChange={(open) => handleSectionOpenChange(section.id, open)}
class="ui:group/section ui:flex ui:min-w-0 ui:flex-col ui:gap-1"
>
<Collapsible.Trigger
class="ui:flex ui:w-full ui:items-center ui:justify-between ui:gap-2 ui:rounded-sm ui:px-3 ui:py-1.5 ui:text-left"
>
<span class="ui:flex ui:min-w-0 ui:items-center ui:gap-1.5">
<ChevronRightIcon
class="ui:size-3.5 ui:shrink-0 ui:text-muted-foreground ui:transition-transform ui:group-data-[state=open]/section:rotate-90"
/>
<span class="ui:truncate ui:font-medium ui:uppercase ui:text-[11px] ui:text-muted-foreground">
{section.title}
</span>
</span>
</Collapsible.Trigger>
<Collapsible.Content class="ui:flex ui:flex-col">
{@render sectionItems(section)}
</Collapsible.Content>
</Collapsible.Root>
{:else}
<div class="ui:flex ui:min-w-0 ui:flex-col ui:gap-2">
<h2 class="ui:m-0 ui:min-w-0 ui:w-full">
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<span
{...mergeProps(
{
class: cn(
'ui:block ui:w-full ui:min-w-0 ui:truncate ui:px-3 ui:font-medium ui:uppercase ui:text-[11px] ui:text-muted-foreground ui:transition-colors'
)
},
props
)}
>
{section.title}
</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={6} class="ui:max-w-xs">
{section.title}
</Tooltip.Content>
</Tooltip.Root>
</h2>
<div class="ui:flex ui:flex-col">
{@render sectionItems(section)}
</div>
</div>
</div>
{/if}
{/each}
</Tooltip.Provider>
</nav>