feat: widgets and ai chat

This commit is contained in:
Rotimi Best
2026-04-27 11:40:38 +01:00
parent ae1801f85f
commit 5717dd8004
391 changed files with 28177 additions and 22467 deletions
@@ -1,6 +1,7 @@
<script lang="ts">
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
import { IconButton } from '@cio/ui/custom/icon-button';
import { t } from '$lib/utils/functions/translations';
@@ -12,9 +13,10 @@
id: string;
title: string;
description: string;
isPublished?: boolean;
}
let { id, title, description }: Props = $props();
let { id, title, description, isPublished = false }: Props = $props();
function redirect(url: string) {
goto(resolve(url, {}));
@@ -40,6 +42,10 @@
$copyCourseModal.title = title;
$copyCourseModal.description = description;
}
function handleOpenCourseSite() {
window.open(`/courses/${id}`, '_blank');
}
</script>
<DropdownMenu.Root>
@@ -52,6 +58,13 @@
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if isPublished}
<DropdownMenu.Item onclick={handleOpenCourseSite}>
<ExternalLinkIcon size={16} class="mr-2" />
{$t('courses.course_card.context_menu.open_course_site')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Item onclick={handleCloneCourse}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
@@ -1,9 +1,11 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import type { Component, Snippet } from 'svelte';
import { resolve } from '$app/paths';
import { CourseCard, DEFAULT_COURSE_BANNER_IMAGE } from '@cio/ui';
import UserIcon from '@lucide/svelte/icons/user';
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
@@ -17,6 +19,11 @@
import { calcCourseDiscount } from '$lib/utils/functions/course';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
import {
getStudentCourseComplianceDate,
getStudentCourseComplianceStatusKey,
getStudentCourseComplianceStatusVariant
} from '$features/course/utils/compliance-utils';
import CardDropdown from './card-dropdown.svelte';
import CoursePublishBadge from './course-publish-badge.svelte';
import CourseTagsOverflow from './course-tags-overflow.svelte';
@@ -117,6 +124,12 @@
icon: UserIcon,
iconStyle: 'custom ui:text-primary'
},
['COMPLIANCE']: {
style: '',
label: $t('course.navItem.settings.compliance'),
icon: ShieldCheckIcon,
iconStyle: 'custom text-emerald-700'
},
SPECIALIZATION: {
style: '',
label: $t('specialization.course_tag'),
@@ -126,9 +139,21 @@
let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount));
let courseUrl = $derived(
href ?? (isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`)
);
let courseUrl = $derived.by(() => {
if (href) {
return href;
}
if (isOnLandingPage || isExplore) {
if (!slug) {
return undefined;
}
return `/course/${slug}`;
}
return `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`;
});
const typeBadge = $derived(
type && COURSE_TAG[type]
@@ -139,9 +164,46 @@
}
: undefined
);
const complianceStatusKey = $derived(
isLMS && type === 'COMPLIANCE' && !isExplore
? getStudentCourseComplianceStatusKey(course as UserEnrolledCourses[number])
: null
);
const complianceStatusVariant = $derived(
isLMS && type === 'COMPLIANCE' && !isExplore
? getStudentCourseComplianceStatusVariant(course as UserEnrolledCourses[number])
: 'outline'
);
const complianceDate = $derived(
isLMS && type === 'COMPLIANCE' && !isExplore
? getStudentCourseComplianceDate(course as UserEnrolledCourses[number])
: null
);
function formatDate(value: string | null | undefined) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium'
}).format(date);
}
</script>
<CourseCard href={resolve(courseUrl, {})} {title} {description} {typeBadge} class="group relative">
<CourseCard
href={courseUrl ? resolve(courseUrl, {}) : undefined}
{title}
{description}
{typeBadge}
class="group relative"
>
{#snippet media()}
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
{/snippet}
@@ -150,7 +212,7 @@
{#if actions}
{@render actions()}
{:else if !isLMS && !isOnLandingPage}
<CardDropdown {id} {title} {description} />
<CardDropdown {id} {title} {description} {isPublished} />
{/if}
{/snippet}
@@ -195,6 +257,22 @@
<Progress value={progressRate} />
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
</div>
{#if type === 'COMPLIANCE'}
<div class="mt-2 flex flex-wrap items-center gap-2">
{#if complianceStatusKey}
<Badge variant={complianceStatusVariant}>
{$t(complianceStatusKey)}
</Badge>
{/if}
{#if complianceDate?.value}
<p class="ui:text-muted-foreground text-xs">
{$t(complianceDate.labelKey)}: {formatDate(complianceDate.value)}
</p>
{/if}
</div>
{/if}
{/if}
{:else}
<CoursePublishBadge {isPublished} />
@@ -7,6 +7,7 @@
type CourseSortOrder
} from '../utils/constants';
import { Checkbox } from '@cio/ui/base/checkbox';
import { Button } from '@cio/ui/base/button';
import { SortPopover } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
@@ -15,7 +16,7 @@
slug: string;
name: string;
color: string;
courseCount: number;
courseCount?: number;
}
interface FilterTagGroup {
@@ -34,8 +35,14 @@
tagGroups?: FilterTagGroup[];
sortOptions?: readonly SortOption[];
isFiltering?: boolean;
hasActiveFilters?: boolean;
publishedStatus?: 'all' | 'published' | 'unpublished';
courseType?: string;
courseTypeOptions?: { value: string; label: string }[];
onToggleTag?: (tagSlug: string, checked: boolean) => void;
onClearFilters?: () => void | Promise<void>;
onPublishedStatusChange?: (status: 'all' | 'published' | 'unpublished') => void;
onCourseTypeChange?: (type: string) => void;
}
let {
@@ -45,8 +52,14 @@
tagGroups = [],
sortOptions = COURSE_SORT_OPTIONS,
isFiltering = false,
hasActiveFilters: hasActiveFiltersOverride = undefined,
publishedStatus = $bindable('all'),
courseType = $bindable('all'),
courseTypeOptions = [],
onToggleTag = () => {},
onClearFilters = () => {}
onClearFilters = () => {},
onPublishedStatusChange,
onCourseTypeChange
}: Props = $props();
const translatedSortOptions = $derived(
@@ -54,12 +67,28 @@
);
const hasActiveFilters = $derived(
sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0
hasActiveFiltersOverride !== undefined
? hasActiveFiltersOverride
: sortKey !== DEFAULT_COURSE_SORT ||
selectedOrder !== DEFAULT_SORT_ORDER ||
selectedTags.length > 0 ||
publishedStatus !== 'all' ||
courseType !== 'all'
);
function isTagSelected(tagSlug: string) {
return selectedTags.includes(tagSlug);
}
function setPublishedStatus(status: 'all' | 'published' | 'unpublished') {
publishedStatus = status;
onPublishedStatusChange?.(status);
}
function setCourseType(type: string) {
courseType = type;
onCourseTypeChange?.(type);
}
</script>
<SortPopover
@@ -74,45 +103,95 @@
>
{#snippet additionalContent()}
<div class="space-y-3">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
{#if tagGroups.length === 0}
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
{:else}
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
{#each tagGroups as group (group.id)}
{#if group.tags.length > 0}
<div class="space-y-2">
<div class="space-y-1">
<p class="text-sm font-medium">{group.name}</p>
{#if group.description}
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
{/if}
</div>
<div class="space-y-2">
{#each group.tags as tag (tag.id)}
<label
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
>
<div class="flex items-center gap-2">
<Checkbox
checked={isTagSelected(tag.slug)}
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
/>
<span
class="inline-block h-2.5 w-2.5 rounded-full border"
style={`background-color: ${tag.color}`}
aria-hidden="true"
></span>
<span class="text-sm">{tag.name}</span>
</div>
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
</label>
{/each}
</div>
</div>
{/if}
{/each}
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('widgets.filter.status')}</p>
<div class="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant={publishedStatus === 'all' ? 'secondary' : 'outline'}
onclick={() => setPublishedStatus('all')}>{$t('widgets.filter.all')}</Button
>
<Button
type="button"
size="sm"
variant={publishedStatus === 'published' ? 'secondary' : 'outline'}
onclick={() => setPublishedStatus('published')}>{$t('widgets.status.published')}</Button
>
<Button
type="button"
size="sm"
variant={publishedStatus === 'unpublished' ? 'secondary' : 'outline'}
onclick={() => setPublishedStatus('unpublished')}>{$t('widgets.filter.unpublished')}</Button
>
</div>
</div>
{#if courseTypeOptions.length > 0}
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('widgets.filter.course_type')}</p>
<div class="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant={courseType === 'all' ? 'secondary' : 'outline'}
onclick={() => setCourseType('all')}>{$t('widgets.filter.all')}</Button
>
{#each courseTypeOptions as opt (opt.value)}
<Button
type="button"
size="sm"
variant={courseType === opt.value ? 'secondary' : 'outline'}
onclick={() => setCourseType(opt.value)}>{opt.label}</Button
>
{/each}
</div>
</div>
{/if}
{#if tagGroups.length > 0}
<div class="space-y-2">
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
{#each tagGroups as group (group.id)}
{#if group.tags.length > 0}
<div class="space-y-2">
<div class="space-y-1">
<p class="text-sm font-medium">{group.name}</p>
{#if group.description}
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
{/if}
</div>
<div class="space-y-2">
{#each group.tags as tag (tag.id)}
<label
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
>
<div class="flex items-center gap-2">
<Checkbox
checked={isTagSelected(tag.slug)}
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
/>
<span
class="inline-block h-2.5 w-2.5 rounded-full border"
style={`background-color: ${tag.color}`}
aria-hidden="true"
></span>
<span class="text-sm">{tag.name}</span>
</div>
{#if tag.courseCount}
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
{/if}
</label>
{/each}
</div>
</div>
{/if}
{/each}
</div>
</div>
{:else if tagGroups.length === 0 && courseTypeOptions.length === 0 && publishedStatus === undefined}
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
{/if}
</div>
{/snippet}
@@ -10,10 +10,13 @@
import * as Empty from '@cio/ui/base/empty';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
import { courseApi } from '$features/course/api';
import { openCoursePreview } from '$features/course/utils/course-preview';
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
import { t } from '$lib/utils/functions/translations';
import { isFreePlan } from '$lib/utils/store/org';
import CoursePublishBadge from './course-publish-badge.svelte';
const siteName = $derived($currentOrg.siteName);
@@ -56,6 +59,13 @@
<span class="grow"></span>
{#if !$isFreePlan}
<Button variant="outline" size="sm" onclick={toggleAiAssistant}>
<SparklesIcon size={14} />
{$t('course.navItems.nav_ai_assistant')}
</Button>
{/if}
<Popover.Root>
<Popover.Trigger>
<Button variant="outline" size="icon">
@@ -20,7 +20,6 @@
import OrderModal from './order-modal.svelte';
import { t } from '$lib/utils/functions/translations';
import * as Dialog from '@cio/ui/base/dialog';
import { IconButton } from '@cio/ui/custom/icon-button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import DeleteConfirmationModal from './delete-confirmation.svelte';
@@ -57,7 +56,6 @@
}: Props = $props();
let questionIdToDelete = $state(null);
let isDeleting = $state(false);
let errors = $derived($questionnaireValidation);
const questionLabels = $derived(getExerciseQuestionLabels());
@@ -181,54 +179,12 @@
return { ...q, questions: next };
});
}
async function handleDelete() {
if (!courseApi.course?.id) return;
isDeleting = true;
await exerciseApi.delete(courseApi.course.id, exerciseId);
if (exerciseApi.success) {
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
goBack();
}
isDeleting = false;
}
</script>
<DeleteConfirmationModal onCancel={() => (questionIdToDelete = null)} onDelete={onFinalDeleteClicked} />
<OrderModal />
<Dialog.Root
bind:open={shouldDeleteExercise}
onOpenChange={(isOpen) => {
if (!isOpen) shouldDeleteExercise = false;
}}
>
<Dialog.Content class="w-2/4">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}</Dialog.Title>
</Dialog.Header>
<form onsubmit={preventDefault()}>
<h1 class="text-xl dark:text-white">
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
</h1>
<div class="mt-5 flex items-center justify-between">
<Button variant="outline" type="submit" onclick={() => (shouldDeleteExercise = false)}>
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.no')}
</Button>
<Button disabled={isDeleting} onclick={handleDelete} loading={isDeleting}>
{isDeleting
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.deleting')
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.yes')}
</Button>
</div>
</form>
</Dialog.Content>
</Dialog.Root>
<div class="mb-20 w-full">
{#if Object.values(errors).length}
<div class="mb-4 flex w-full justify-center">
@@ -29,10 +29,12 @@
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { toggleConfetti } from '$features/ui/confetti/store';
import { ContentType } from '@cio/utils/constants/content';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { isOrgStudent } from '$lib/utils/store/app';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels';
import axios from 'axios';
@@ -152,6 +154,9 @@
}
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
toggleConfetti();
setTimeout(toggleConfetti, 3000);
const allContentItems = getOrderedNavigableContent(courseApi.course);
const allComplete =
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
@@ -573,7 +578,7 @@
</div>
</div>
</div>
{:else if courseApi.course?.type === 'SELF_PACED'}
{:else if isSelfPacedLikeCourse(courseApi.course?.type)}
<Badge
variant="success"
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_submitted')}
@@ -25,6 +25,7 @@
import ContentRow from './content-row.svelte';
import ContentActions from './content-actions.svelte';
import { deleteContent, saveContent, toggleLock } from './content-action-helpers';
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
const flipDurationMs = 300;
@@ -45,7 +46,7 @@
const contentData = $derived(getCourseContent(courseApi.course));
const isStudentView = $derived($isOrgStudent === true);
const isLiveCourse = $derived(courseApi.course?.type === 'LIVE_CLASS');
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type));
const metaChipClass =
'inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[10px] text-gray-700 dark:bg-neutral-700 dark:text-gray-200';
@@ -262,7 +263,7 @@
/>
<section
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4 {reorder ? 'cursor-grab active:cursor-grabbing' : ''}"
class="mx-auto w-full p-3 lg:w-11/12 {reorder ? 'cursor-grab active:cursor-grabbing' : ''}"
use:dndzone={{
items: contentItems,
flipDurationMs,
@@ -63,9 +63,7 @@
const showMarkComplete = $derived(!!lessonId && !exerciseId);
const currentLessonItem = $derived(lessonId ? lessonItems.find((l) => l.id === lessonId) : null);
const isLessonLocked = $derived(
$isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false)
);
const isLessonLocked = $derived($isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false));
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked || isLessonComplete);
async function markLessonComplete(currentLessonId: string) {
@@ -283,6 +283,7 @@
const wasDeleted = await contentApi.deleteContent(courseId, { sectionId: deletingData.id });
if (wasDeleted) {
removeSection(deletingData.id);
snackbar.success('snackbar.lessons.section_deleted');
}
return;
}
@@ -438,7 +439,7 @@
<DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} />
<section
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4"
class="mx-auto w-full p-3 lg:w-11/12"
use:dndzone={{
items: sections,
flipDurationMs,
@@ -462,7 +463,8 @@
: `${$t('course.navItem.lessons.add_lesson.unlock')} ${$t('course.navItem.lessons.add_lesson.all')}`
: undefined}
<div
class="border-border m-auto mb-3 max-w-xl rounded-md border transition dark:bg-neutral-800"
id={isUngrouped ? undefined : `section-${section.id}`}
class="border-border m-auto mb-3 max-w-xl scroll-mt-24 rounded-md border transition dark:bg-neutral-800"
style={reorder ? 'border: 2px #1d4ed8 solid; border-style: dashed; cursor: grab;' : ''}
>
<SectionHeader
@@ -518,7 +520,7 @@
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
<div
class="mb-2 flex min-h-[50px] max-w-xl items-start justify-between rounded-md border border-gray-200 px-3 py-1 transition"
class="ui:border mb-2 flex min-h-[50px] max-w-xl items-start justify-between rounded-md px-3 py-1 transition"
style={reorder ? 'border: 2px #1d4ed8 solid; border-style: dashed; cursor: grab;' : ''}
animate:flip={{ duration: flipDurationMs }}
>
@@ -67,7 +67,7 @@
{:else}
<div class="flex w-4/6 items-center gap-2">
<CourseContentIcon type={ContentType.Section} size={16} />
<p class="font-semibold">{section.title}</p>
<p class="text-sm">{section.title}</p>
<ContentCountBadges lessons={sectionCount.lessons} exercises={sectionCount.exercises} />
</div>
{/if}
@@ -30,6 +30,13 @@
subtitle: $t('new_course_modal.self_paced_subtitle'),
type: 'SELF_PACED' as TCourseType,
isDisabled: false
},
{
id: 'compliance',
title: $t('new_course_modal.compliance_label'),
subtitle: $t('new_course_modal.compliance_subtitle'),
type: 'COMPLIANCE' as TCourseType,
isDisabled: false
}
];
const courseTypeOptionsForGroup = options.map((o) => ({
@@ -68,7 +75,7 @@
{#snippet course_type_selector()}
<Field.Description>{$t('courses.new_course_modal.type_selector_title')}</Field.Description>
<RadioOptionCardGroup options={courseTypeOptionsForGroup} bind:value={type} class="grid-cols-2" />
<RadioOptionCardGroup options={courseTypeOptionsForGroup} bind:value={type} class="grid-cols-1 md:grid-cols-3" />
{/snippet}
<Dialog.Root
@@ -84,7 +84,7 @@
id={feed.id}
class="flex flex-col justify-between gap-2 {isActive
? 'border-primary-700 border-2'
: 'border border-gray-200'} mb-7 w-[90%] rounded-md md:max-w-3xl"
: 'ui:border'} mb-7 w-[90%] rounded-md md:max-w-3xl"
>
<Header {feed} {onPin} onEdit={openEditFeed} onRequestDelete={() => (isDeleteFeedModal = true)} />
@@ -124,7 +124,7 @@
};
</script>
<section class="border-t border-gray-200 p-3">
<section class="ui:border-t p-3">
<div class="flex w-full items-center justify-between">
<Button
variant="ghost"
@@ -201,7 +201,7 @@
bind:value={comment}
onkeydown={handleAddNewComment}
placeholder={$t('course.navItem.news_feed.comments.placeholder')}
class="w-full rounded-3xl border border-gray-200 bg-transparent p-1 pl-2 text-sm disabled:opacity-50"
class="ui:border w-full rounded-3xl bg-transparent p-1 pl-2 text-sm disabled:opacity-50"
disabled={isSubmitting}
required
/>
@@ -1,13 +1,12 @@
<script lang="ts">
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import * as Avatar from '@cio/ui/base/avatar';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import UserIcon from '@lucide/svelte/icons/user';
import { calDateDiff } from '$lib/utils/functions/date';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { UserAvatar } from '@cio/ui/custom/user-avatar';
import type { Feed } from '$features/course/utils/types';
import { HTMLRender, RoleBasedSecurity } from '$features/ui';
@@ -25,19 +24,7 @@
<div class="px-3 pt-3 pb-0">
<div class="mb-2 flex justify-between">
<span class="flex items-center gap-3">
<div class="h-9 w-9">
<Avatar.Root class="h-full w-full">
<Avatar.Image
src={feed.authorAvatarUrl ?? ''}
alt={$t('course.navItem.news_feed.user_avatar_alt')}
loading="lazy"
decoding="async"
/>
<Avatar.Fallback aria-hidden="true">
<UserIcon class="text-muted-foreground h-5 w-5" />
</Avatar.Fallback>
</Avatar.Root>
</div>
<UserAvatar src={feed.authorAvatarUrl} />
<span>
<p class="text-base font-semibold capitalize">{feed.authorFullname || ''}</p>
<p class="text-sm font-medium text-gray-600">{calDateDiff(feed.createdAt)}</p>
@@ -2,7 +2,7 @@
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="mb-7 flex w-full max-w-3xl flex-col gap-3 rounded-md border border-gray-300 p-3">
<div class="ui:border mb-7 flex w-full max-w-3xl flex-col gap-3 rounded-md p-3">
<div class="flex items-center gap-2">
<Skeleton class="h-8 w-8 overflow-hidden rounded-full" />
<Skeleton class="h-4 w-[30%]" />
@@ -191,7 +191,7 @@
</UnderlineTabs.List>
<UnderlineTabs.Content value="tutors">
<div class="mt-6 space-y-6">
<div class="space-y-3">
<TutorSelectSection
{tutors}
bind:selectedIds
@@ -208,13 +208,14 @@
</UnderlineTabs.Content>
<UnderlineTabs.Content value="students">
<div class="mt-6 space-y-6">
<div class="space-y-6">
<ExistingStudentsSection
students={availableStudents}
isLoading={isLoadingStudents}
onSearchValueChange={handleStudentSearch}
onAssign={assignExistingStudents}
/>
<BulkEmailSection onInvite={inviteNewStudents} />
</div>
</UnderlineTabs.Content>
@@ -4,10 +4,17 @@ export const NAV_IDS = {
ATTENDANCE: 'ATTENDANCE',
SUBMISSIONS: 'SUBMISSIONS',
MARKS: 'MARKS',
COMPLIANCE: 'COMPLIANCE',
CERTIFICATES: 'CERTIFICATES',
LANDING_PAGE: 'LANDING_PAGE',
PEOPLE: 'PEOPLE',
ANALYTICS: 'ANALYTICS',
SETTINGS: 'SETTINGS',
SECTION: 'SECTION'
SECTION: 'SECTION',
AI_ASSISTANT: 'AI_ASSISTANT'
};
export const COURSE_SIDEBAR_DEFAULT_WIDTH = 256;
export const COURSE_SIDEBAR_MIN_WIDTH = 220;
export const COURSE_SIDEBAR_MAX_WIDTH = 420;
export const COURSE_SIDEBAR_STORAGE_KEY = 'classroomio-course-sidebar-width';
@@ -2,6 +2,7 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Badge } from '@cio/ui/base/badge';
import Plus from '@lucide/svelte/icons/plus';
import * as Sidebar from '@cio/ui/base/sidebar';
import { BackButton } from '@cio/ui';
@@ -27,7 +28,7 @@
import ContentCountBadges from '../content-count-badges.svelte';
import { NAV_IDS } from './constants';
import { courseApi } from '$features/course/api';
import { complianceApi, courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { currentOrg, isFreePlan, currentOrgPath } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
@@ -35,6 +36,7 @@
import { useSidebar } from '@cio/ui/base/sidebar';
import { IconButton } from '@cio/ui/custom/icon-button';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { profile } from '$lib/utils/store/user';
interface Props {
path: string;
@@ -61,6 +63,7 @@
};
});
const showContentCount = $derived(sidebar.open && !sidebar.isMobile && contentCount.total > 0);
const studentComplianceRecord = $derived(complianceApi.learnerHistory?.currentRecord ?? null);
const navItems = $derived(
[
@@ -130,6 +133,16 @@
},
icon: getNavIcon(NAV_IDS.MARKS)
},
{
id: NAV_IDS.COMPLIANCE,
title: $t('course.navItems.nav_compliance'),
url: getNavItemRoute(id, 'compliance'),
isActive: (path || page.url.pathname) === getNavItemRoute(id, 'compliance'),
show() {
return courseApi.course?.type === 'COMPLIANCE';
},
icon: getNavIcon(NAV_IDS.COMPLIANCE)
},
{
id: NAV_IDS.CERTIFICATES,
title: $t('course.navItems.nav_certificates'),
@@ -176,6 +189,14 @@
].filter((item) => !item.show || item.show())
);
$effect(() => {
if (!isStudent || courseApi.course?.type !== 'COMPLIANCE' || !courseApi.course?.id || !$profile.id) {
return;
}
void complianceApi.ensureLearnerHistory(courseApi.course.id, $profile.id);
});
function openContentModal(courseId: string, sectionId = '') {
goto(resolve(`/courses/${courseId}/lessons`, {}));
contentEditingStore.set(undefined);
@@ -213,6 +234,8 @@
return SubmissionIcon;
} else if (id === NAV_IDS.MARKS) {
return MarksIcon;
} else if (id === NAV_IDS.COMPLIANCE) {
return CertificateIcon;
} else if (id === NAV_IDS.PEOPLE) {
return PeopleIcon;
} else if (id === NAV_IDS.ANALYTICS) {
@@ -227,11 +250,77 @@
return null;
}
function getStatusVariant(status: string | null | undefined) {
if (status === 'compliant') {
return 'success';
}
if (status === 'expiring_soon' || status === 'in_progress' || status === 'in_grace_period') {
return 'warning';
}
if (status === 'non_compliant') {
return 'destructive';
}
if (status === 'waived') {
return 'secondary';
}
return 'outline';
}
function formatDate(value: string | null | undefined) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium'
}).format(date);
}
</script>
<Sidebar.Group class="pt-0!">
<BackButton href={resolve(coursesListPath, {})} label={$t('org_navigation.courses')} class="px-2! py-2!" />
{#if isStudent && courseApi.course?.type === 'COMPLIANCE'}
<div
class="mx-2 mb-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 dark:border-emerald-900/50 dark:bg-emerald-950/20"
>
<div class="flex items-center justify-between gap-2">
<p class="text-xs font-semibold tracking-wide text-emerald-900 uppercase dark:text-emerald-100">
{$t('course.sidebar.compliance.title')}
</p>
{#if studentComplianceRecord?.status}
<Badge variant={getStatusVariant(studentComplianceRecord.status)}>
{$t(`course.navItem.compliance.status.${studentComplianceRecord.status}`)}
</Badge>
{/if}
</div>
<div class="mt-2 space-y-1 text-xs text-emerald-900 dark:text-emerald-100">
{#if studentComplianceRecord?.dueDate}
<p>
{$t('course.sidebar.compliance.due_date')}: {formatDate(studentComplianceRecord.dueDate)}
</p>
{/if}
{#if studentComplianceRecord?.validUntil}
<p>
{$t('course.sidebar.compliance.valid_until')}: {formatDate(studentComplianceRecord.validUntil)}
</p>
{/if}
</div>
</div>
{/if}
<Sidebar.Menu>
{#each navItems as item (item.id)}
{#if item.isLesson}
@@ -1,4 +1,5 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { page } from '$app/state';
import * as Sidebar from '@cio/ui/base/sidebar';
import { Skeleton } from '@cio/ui/base/skeleton';
@@ -12,6 +13,8 @@
import SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
import UpgradePoweredBy from '$features/ui/upgrade-powered-by.svelte';
import { useSidebar } from '@cio/ui/base/sidebar';
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
import { COURSE_SIDEBAR_DEFAULT_WIDTH, COURSE_SIDEBAR_MAX_WIDTH, COURSE_SIDEBAR_MIN_WIDTH } from './constants';
const isOrgLoaded = $derived($orgs.length > 0 && $profile.id);
@@ -23,12 +26,86 @@
id: string;
/** If false, sidebar nav shows loading skeletons. Omit or true = show real nav. */
isCourseReady?: boolean;
sidebarWidth?: number;
onSidebarWidthPreview?: (width: number) => void;
onSidebarWidthChange?: (width: number) => void;
}
let { path, id, isCourseReady = true }: Props = $props();
let {
path,
id,
isCourseReady = true,
sidebarWidth = COURSE_SIDEBAR_DEFAULT_WIDTH,
onSidebarWidthPreview = () => {},
onSidebarWidthChange = () => {}
}: Props = $props();
const sidebar = useSidebar();
const currentPath = $derived(path || page.url.pathname);
let shouldIgnoreRailClick = $state(false);
let stopSidebarResize: (() => void) | null = null;
function clampSidebarWidth(width: number) {
return Math.min(COURSE_SIDEBAR_MAX_WIDTH, Math.max(COURSE_SIDEBAR_MIN_WIDTH, width));
}
function clearSidebarResizeListeners() {
stopSidebarResize?.();
stopSidebarResize = null;
}
function handleRailPointerDown(event: PointerEvent) {
if (sidebar.isMobile || !sidebar.open) {
return;
}
event.preventDefault();
event.stopPropagation();
clearSidebarResizeListeners();
const startWidth = sidebarWidth;
stopSidebarResize = startResizablePanelDrag({
event,
startWidth,
resolveWidth: ({ startWidth, deltaX }) => clampSidebarWidth(startWidth + deltaX),
onPreview: onSidebarWidthPreview,
onCommit: ({ hasDragged, width }) => {
if (hasDragged) {
onSidebarWidthChange(width);
}
},
onDragStart: () => {
document.body.dataset.courseSidebarResizing = 'true';
},
onDragEnd: ({ hasDragged }) => {
shouldIgnoreRailClick = hasDragged;
delete document.body.dataset.courseSidebarResizing;
stopSidebarResize = null;
if (hasDragged) {
window.setTimeout(() => {
shouldIgnoreRailClick = false;
}, 0);
}
}
});
}
function handleRailClick(event: MouseEvent) {
if (shouldIgnoreRailClick) {
event.preventDefault();
event.stopPropagation();
return;
}
sidebar.toggle();
}
onDestroy(() => {
clearSidebarResizeListeners();
});
</script>
{#if !isOrgLoaded}
@@ -62,7 +139,7 @@
{/if}
</Sidebar.Content>
<Sidebar.Rail />
<Sidebar.Rail onclick={handleRailClick} onpointerdown={handleRailPointerDown} />
{#if $isFreePlan}
<Sidebar.Footer>
@@ -74,3 +151,10 @@
{/if}
</Sidebar.Root>
{/if}
<style>
:global(body[data-course-sidebar-resizing='true'] [data-slot='sidebar-gap']),
:global(body[data-course-sidebar-resizing='true'] [data-slot='sidebar-container']) {
transition: none !important;
}
</style>