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:
co-authored by
Rotimi Best
parent
bf3a7e7057
commit
55ae6dd4fe
@@ -0,0 +1,5 @@
|
||||
# CodeRabbit configuration — https://docs.coderabbit.ai/reference/configuration
|
||||
reviews:
|
||||
path_filters:
|
||||
# Design/exploration HTML mocks — not shipped application code
|
||||
- '!prototypes/**'
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ignorePatterns": "prototypes/**\n**/*.lock\n**/dist/**\n**/node_modules/**"
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
+90
@@ -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} />
|
||||
+63
@@ -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>
|
||||
+209
@@ -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';
|
||||
+111
-90
@@ -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>
|
||||
|
||||
+107
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user