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