Files
classroomio/apps/dashboard/src/lib/features/course/components/sidebar/course-content-tree.svelte
T
Rotimi Best 81294a47f9 feat: live class sessions, CE workflow, and docs (#683)
Adds live-class session scheduling on lessons (join link, date/time,
timezone) with automatic 24h and 1h reminder emails plus a calendar
invite (.ics), a time-change notification with an updated invite, a
teacher-authored welcome email and session invite on enrollment, and a
"Notify students" quiz nudge.

LMS gets an upcoming live-sessions card and Join buttons, plus a
reusable live-session card component (3 states) in @cio/ui with a
Storybook story. The sidebar content tree now reflects sequential
progression locking like the content list, and the course-completion
confetti regression is fixed.

Org-based emails no longer reference ClassroomIO by name. Docs add
live-class, welcome-email, and course-progression guides, expand the
certificates guide (design, signatures, corrected labels), move
plan-required callouts to the top, flatten how-to-guides to the docs
root with redirects, and add screenshots. Includes an add-docs-image
skill.
2026-06-22 05:43:48 +01:00

186 lines
7.9 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import Plus from '@lucide/svelte/icons/plus';
import LockIcon from '@lucide/svelte/icons/lock';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import * as Sidebar from '@cio/ui/base/sidebar';
import * as Collapsible from '@cio/ui/base/collapsible';
import { ContentType } from '@cio/utils/constants/content';
import { courseApi } from '$features/course/api';
import { getContentRoute, getCourseContent } from '$features/course/utils/content';
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';
interface Props {
path: string;
id: string;
isStudent?: boolean;
className?: string;
onOpenContentModal?: (sectionId?: string) => void;
onEditSection?: (sectionId: string) => void;
}
let { path, id, isStudent = false, className = '', onOpenContentModal }: Props = $props();
const contentData = $derived(getCourseContent(courseApi.course));
const canOpenContentModal = $derived(Boolean(onOpenContentModal) && !isStudent);
</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}
<Sidebar.MenuSub class={className}>
{#if contentData.grouped}
{#each contentData.sections as section (section.id)}
<Collapsible.Root open={true} class="group/section">
{#snippet child({ props })}
<Sidebar.MenuSubItem {...props}>
<Collapsible.Trigger>
{#snippet child({ props })}
<Sidebar.MenuSubButton {...props} class="flex w-full items-center gap-2 font-medium">
<CourseContentIcon type={ContentType.Section} size={12} />
<span class="flex-1 truncate">{section.title}</span>
<div class="ml-auto flex items-center gap-1">
{#if canOpenContentModal}
<IconButton
variant="ghost-outline"
size="icon-xs"
class="opacity-0 transition-opacity duration-150 group-hover/section:opacity-100"
onclick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenContentModal(section.id);
}}
>
<Plus />
</IconButton>
{/if}
<IconButton variant="ghost" size="icon-xs">
<ChevronRightIcon
class="transition-transform duration-200 group-data-[state=open]/section:rotate-90"
/>
</IconButton>
</div>
</Sidebar.MenuSubButton>
{/snippet}
</Collapsible.Trigger>
<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 = isStudent && (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>
{/each}
</Sidebar.MenuSub>
</Collapsible.Content>
</Sidebar.MenuSubItem>
{/snippet}
</Collapsible.Root>
{/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 = isStudent && (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>
{/each}
{/if}
</Sidebar.MenuSub>
<style>
a {
text-decoration: none;
}
</style>