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
@@ -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>