feat: widgets and ai chat
This commit is contained in:
@@ -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';
|
||||
|
||||
+90
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user