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.
186 lines
7.9 KiB
Svelte
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>
|