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,10 +1,24 @@
import type { DashStatsSuccess } from '$features/org/utils/types';
import type { DashStatsSuccess, LoginActivityData, LoginActivitySuccess } from '$features/org/utils/types';
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
import { safeServerApi } from '$lib/utils/services/api/server';
import { type ServerApiResult, safeServerApi } from '$lib/utils/services/api/server';
// TODO - Replace with actual cache
const cache: Record<string, DashStatsSuccess['data'] | null> = {};
function loginActivityDataFromSettled(
result: PromiseSettledResult<ServerApiResult<LoginActivitySuccess>>
): LoginActivityData {
if (result.status === 'rejected') {
return [];
}
if (!result.value.ok) {
return [];
}
return result.value.body.data;
}
export const load = async ({ params, parent, cookies }) => {
const { orgId } = await parent();
const siteName = params.slug;
@@ -12,29 +26,37 @@ export const load = async ({ params, parent, cookies }) => {
if (!orgId) {
return {
orgName: siteName,
stats: null
stats: null,
loginActivity: [] as LoginActivityData
};
}
if (orgId in cache) {
const loginActivityResult = await safeServerApi<LoginActivitySuccess>(() =>
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
);
return {
orgName: siteName,
stats: cache[orgId]
stats: cache[orgId],
loginActivity: loginActivityResult.ok ? loginActivityResult.body.data : []
};
}
try {
const result = await safeServerApi<DashStatsSuccess>(() =>
const [statsResult, loginActivityResult] = await Promise.allSettled([
safeServerApi<DashStatsSuccess>(() =>
classroomio.dash.stats.$get({ query: { siteName } }, getApiHeaders(cookies, orgId))
);
cache[orgId] = result.ok ? result.body.data : null;
} catch (error) {
console.error('Failed to fetch dashboard stats:', error);
cache[orgId] = null;
}
),
safeServerApi<LoginActivitySuccess>(() =>
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
)
]);
cache[orgId] = statsResult.status === 'fulfilled' && statsResult.value.ok ? statsResult.value.body.data : null;
return {
orgName: siteName,
stats: cache[orgId]
stats: cache[orgId],
loginActivity: loginActivityDataFromSettled(loginActivityResult)
};
};
@@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import AwardIcon from '@lucide/svelte/icons/award';
import BookIcon from '@lucide/svelte/icons/book';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { Skeleton } from '@cio/ui/base/skeleton';
import UsersIcon from '@lucide/svelte/icons/users';
@@ -17,12 +18,14 @@
import { Button } from '@cio/ui/base/button';
import { WelcomeModal } from '$features/onboarding/components';
import { ActivityCard, VisitOrgSiteButton } from '$features/ui';
import LoginActivityChart from '$features/org/components/login-activity-chart.svelte';
import * as Page from '@cio/ui/base/page';
import { Empty } from '@cio/ui/custom/empty';
const { data } = $props();
const stats = $derived(data.stats);
const loginActivity = $derived(data.loginActivity);
const recentCertifications = $derived(stats?.recentCertifications || []);
const numberOfCourses = $derived(stats?.numberOfCourses || 0);
const totalCertificates = $derived(stats?.totalCertificates || 0);
@@ -85,57 +88,94 @@
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:text-white">
<h3 class="mb-4 text-lg">
{$t('dashboard.top_courses')}
</h3>
<div class="bg-card flex min-h-[45vh] w-full flex-col rounded-xl border p-3 md:p-5 dark:text-white">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold tracking-tight">
{$t('dashboard.top_courses')}
</h3>
</div>
<div class="h-full space-y-6">
<div class="flex h-full flex-col">
{#if !stats}
{#each Array(5) as _, i (i)}
<Skeleton class="h-10 w-full rounded-md" />
{/each}
<div class="space-y-3">
{#each Array(5) as _, i (i)}
<Skeleton class="h-16 w-full rounded-lg" />
{/each}
</div>
{:else if topCourses.length === 0}
<Empty
title={$t('dashboard.create_first_course')}
description={$t('dashboard.create_first_course_description')}
icon={BookIcon}
class="h-full"
>
<CreateCourseButton variant="outline" />
</Empty>
{:else}
{#each topCourses as course (course.id)}
<div class="flex items-center gap-2">
<div class="w-4/6 space-y-1">
<a class="hover:underline" href={`/courses/${course.id}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
{course.title}
</p>
<ul class="ui:divide-border -mx-2 divide-y">
{#each topCourses as course, i (course.id)}
{@const rank = i + 1}
<li>
<a
href={`/courses/${course.id}`}
class="group ui:hover:bg-muted/50 flex items-center gap-3 rounded-lg px-2 py-3 transition-colors md:gap-4"
>
<span
class={[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold tabular-nums',
rank === 1 &&
'ui:bg-amber-100 ui:text-amber-900 ui:dark:bg-amber-500/20 ui:dark:text-amber-200',
rank === 2 &&
'ui:bg-slate-100 ui:text-slate-700 ui:dark:bg-slate-500/20 ui:dark:text-slate-200',
rank === 3 &&
'ui:bg-orange-100 ui:text-orange-800 ui:dark:bg-orange-500/20 ui:dark:text-orange-200',
rank > 3 && 'ui:bg-muted ui:text-muted-foreground'
]}
>
{rank}
</span>
<div class="min-w-0 flex-1">
<p class="line-clamp-1 text-sm leading-snug font-medium group-hover:underline">
{course.title}
</p>
<div class="ui:text-muted-foreground mt-1 flex items-center gap-1 text-xs">
<UsersIcon class="h-3 w-3" />
<span class="tabular-nums">{course.enrollments}</span>
<span>
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
</span>
</div>
</div>
<div class="hidden w-48 shrink-0 grid-cols-2 gap-3 sm:grid">
<div class="space-y-1">
<div class="flex items-baseline justify-between gap-1">
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
{$t('dashboard.completion')}
</span>
<span class="text-xs font-semibold tabular-nums">{course.completion}%</span>
</div>
<Progress value={course.completion} class="ui:h-1.5" />
</div>
<div class="space-y-1">
<div class="flex items-baseline justify-between gap-1">
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
{$t('dashboard.certification_rate')}
</span>
<span class="text-xs font-semibold tabular-nums">{course.certification}%</span>
</div>
<Progress value={course.certification} class="ui:h-1.5" />
</div>
</div>
<ChevronRightIcon
class="ui:text-muted-foreground h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
/>
</a>
<p class="text-sm">
{course.enrollments}
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
</p>
</div>
<div class="ml-auto w-2/6 space-y-2">
<div>
<Progress value={course.completion} />
<div class="text-sm">
{course.completion}%
{$t('dashboard.completion')}
</div>
</div>
<div>
<Progress value={course.certification} />
<div class="text-sm">
{course.certification}%
{$t('dashboard.certification_rate')}
</div>
</div>
</div>
</div>
{:else}
<Empty
title={$t('dashboard.create_first_course')}
description={$t('dashboard.create_first_course_description')}
icon={BookIcon}
class="h-full"
>
<CreateCourseButton variant="outline" />
</Empty>
{/each}
</li>
{/each}
</ul>
{/if}
</div>
</div>
@@ -190,6 +230,10 @@
</div>
</div>
</div>
<div class="mt-4">
<LoginActivityChart data={loginActivity} />
</div>
{/snippet}
</Page.Body>
</Page.Root>
@@ -15,8 +15,6 @@ export const load = async ({ parent, locals, cookies }) => {
classroomio.program.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
);
console.log('result', result.ok ? result.body.data : []);
return {
programs: result.ok ? result.body.data : []
};
@@ -21,6 +21,7 @@
let { data } = $props();
$effect(() => {
console.log('data', data);
if (data.setupProgress && data.orgSiteName) {
setupProgressApi.initializeFromServerData(data.setupProgress, data.orgSiteName);
}
@@ -0,0 +1,26 @@
import type { GetWidgetsSuccess } from '$features/widget/utils/types';
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
import { safeServerApi } from '$lib/utils/services/api/server';
import { redirect } from '@sveltejs/kit';
export const load = async ({ parent, cookies, params }) => {
const { orgId } = await parent();
if (!orgId) {
return {
initialWidgets: []
};
}
const result = await safeServerApi<GetWidgetsSuccess>(() =>
classroomio.organization.widgets.$get({}, getApiHeaders(cookies, orgId))
);
if (!result.ok && (result.status === 401 || result.status === 403)) {
throw redirect(302, `/org/${params.slug}`);
}
return {
initialWidgets: result.ok ? result.body.data : []
};
};
@@ -0,0 +1,70 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import * as Page from '@cio/ui/base/page';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { InputField } from '@cio/ui/custom/input-field';
import { WidgetsPage } from '$features/widget/pages';
import { widgetApi } from '$features/widget';
import { t } from '$lib/utils/functions/translations';
let { data } = $props();
let createModalOpen = $state(false);
let newWidgetName = $state('');
async function handleCreate() {
if (!newWidgetName.trim()) return;
const widget = await widgetApi.createWidget({
name: newWidgetName.trim(),
layoutType: 'card_grid',
selectionMode: 'manual',
config: undefined,
selectedCourseIds: []
});
if (!widget) return;
createModalOpen = false;
newWidgetName = '';
goto(resolve(`/widgets/${widget.id}`, {}));
}
</script>
<svelte:head>
<title>{$t('widgets.heading')} - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('widgets.heading')}</Page.Title>
<Page.Subtitle>{$t('widgets.subtitle')}</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button onclick={() => (createModalOpen = true)}>{$t('widgets.actions.create')}</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<WidgetsPage initialWidgets={data.initialWidgets} onCreate={() => (createModalOpen = true)} />
{/snippet}
</Page.Body>
</Page.Root>
<Dialog.Root bind:open={createModalOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('widgets.actions.create')}</Dialog.Title>
<Dialog.Description>{$t('widgets.create.description')}</Dialog.Description>
</Dialog.Header>
<InputField label={$t('widgets.form.name')} bind:value={newWidgetName} />
<Dialog.Footer>
<Button variant="outline" onclick={() => (createModalOpen = false)}>{$t('app.cancel')}</Button>
<Button disabled={!newWidgetName.trim()} onclick={handleCreate}>{$t('widgets.actions.create')}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>