chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
@@ -14,6 +14,8 @@
import ContentCreateModal from '$features/course/components/content/content-create-modal.svelte';
import CourseCompletionModal from '$features/course/components/ceritficate/course-completion-modal.svelte';
import { AiCourseChat } from '$features/ai-assistant';
import { initialChatPrompt, openAiAssistant } from '$features/ai-assistant/utils/store';
import { get } from 'svelte/store';
import { profile } from '$lib/utils/store/user';
import { isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
@@ -98,6 +100,10 @@
}
hasLoadedSidebarWidth = true;
if (get(initialChatPrompt)) {
openAiAssistant();
}
});
$effect(() => {
@@ -180,5 +186,5 @@
{/if}
</Sidebar.Inset>
<AiCourseChat />
<AiCourseChat courseId={data.courseId} />
</Sidebar.Provider>
@@ -31,7 +31,7 @@
});
</script>
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
<Page.Root class="mx-auto flex w-full px-3 sm:w-[90%] sm:px-4 lg:max-w-5xl">
{#if isLockedForStudent}
<Empty
title={$t('course.navItem.lessons.content_locked_title')}
@@ -30,7 +30,7 @@ export const load = async ({ params, url, cookies }) => {
if (!orgId) {
const apiKeyHeaders = getApiKeyHeaders();
const org = await getOrgBySiteName(siteName, apiKeyHeaders);
console.log('org', org);
if (org?.id) {
orgId = org.id;
@@ -1,62 +1,5 @@
import type { DashStatsSuccess, LoginActivityData, LoginActivitySuccess } from '$features/org/utils/types';
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
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;
if (!orgId) {
return {
orgName: siteName,
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],
loginActivity: loginActivityResult.ok ? loginActivityResult.body.data : []
};
}
const [statsResult, loginActivityResult] = await Promise.allSettled([
safeServerApi<DashStatsSuccess>(() =>
classroomio.dash.stats.$get({ query: { siteName } }, getApiHeaders(cookies, orgId))
),
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;
export const load = async ({ params }) => {
return {
orgName: siteName,
stats: cache[orgId],
loginActivity: loginActivityDataFromSettled(loginActivityResult)
orgName: params.slug
};
};
@@ -1,239 +1,198 @@
<script lang="ts">
import { onMount } from 'svelte';
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';
import { resolve } from '$app/paths';
import CheckCircleIcon from '@lucide/svelte/icons/circle-check';
import CircleIcon from '@lucide/svelte/icons/circle';
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date';
import { getGreeting } from '$lib/utils/functions/date';
import { currentOrgPath } from '$lib/utils/store/org';
import { CourseCreator } from '@cio/ui/custom/course-creator';
import { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
import { courseApi } from '$features/course/api';
import { setInitialChatModel, setInitialChatPrompt } from '$features/ai-assistant/utils/store';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { isFreePlan } from '$lib/utils/store/org';
import { AI_CHAT_MODEL_STORAGE_KEY } from '$features/ai-assistant/utils/constants';
import type { TCourseType } from '@cio/db/types';
import {
AGENT_MODELS,
DEFAULT_PICKER_MODEL_ID,
UI_PICKER_MODEL_IDS,
type AgentModelId
} from '@cio/utils/agent-models';
import { CreateCourseButton } from '$features/course/components';
import { UserAvatar } from '@cio/ui/custom/user-avatar';
import { Progress } from '@cio/ui/base/progress';
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';
type CreatingStep = 'reading' | 'naming' | 'building';
const { data } = $props();
const steps: { key: CreatingStep; label: string }[] = [
{ key: 'reading', label: t.get('course.creator.drafting.step_reading') },
{ key: 'naming', label: t.get('course.creator.drafting.step_naming') },
{ key: 'building', label: t.get('course.creator.drafting.step_building') }
];
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);
const totalStudents = $derived(stats?.totalStudents || 0);
const topCourses = $derived(stats?.topCourses || []);
let creatingState: 'idle' | 'creating' = $state('idle');
let creatingStep: CreatingStep = $state('reading');
let draftingPrompt = $state('');
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
function isAgentModelId(value: unknown): value is AgentModelId {
return typeof value === 'string' && (UI_PICKER_MODEL_IDS as readonly string[]).includes(value);
}
function stepStatus(key: CreatingStep): 'done' | 'active' | 'pending' {
const order: CreatingStep[] = ['reading', 'naming', 'building'];
const current = order.indexOf(creatingStep);
const target = order.indexOf(key);
if (target < current) return 'done';
if (target === current) return 'active';
return 'pending';
}
function handleModelChange(model: string) {
if (!isAgentModelId(model)) return;
if ($isFreePlan && paidModelIds.includes(model)) {
openUpgradeModal();
return;
}
selectedModel = model;
try {
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, model);
} catch {
// localStorage unavailable
}
}
function handleLockedModelSelect() {
openUpgradeModal();
}
$effect(() => {
if (!$isFreePlan || !paidModelIds.includes(selectedModel)) {
return;
}
selectedModel = DEFAULT_PICKER_MODEL_ID;
try {
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, DEFAULT_PICKER_MODEL_ID);
} catch {
// localStorage unavailable
}
});
async function handleCreate({ prompt, level, model }: { prompt: string; level: string; model?: string }) {
if (isAgentModelId(model)) {
if ($isFreePlan && paidModelIds.includes(model)) {
openUpgradeModal();
return;
}
handleModelChange(model);
}
draftingPrompt = prompt;
creatingState = 'creating';
creatingStep = 'reading';
await new Promise((r) => setTimeout(r, 700));
creatingStep = 'naming';
const meta = await aiAssistantApi.generateCourseMeta(prompt);
const title = meta?.title ?? prompt.slice(0, 80);
const description = meta?.description ?? prompt.slice(0, 150);
creatingStep = 'building';
setInitialChatPrompt(`${prompt}\n\nCourse type: Self-paced. Level: ${level}.`);
setInitialChatModel(selectedModel);
await courseApi.create({ title, description, type: 'SELF_PACED' as TCourseType }, (courseId) => {
goto(resolve(`/courses/${courseId}`, {}));
});
}
onMount(() => {
try {
const storedModel = localStorage.getItem(AI_CHAT_MODEL_STORAGE_KEY);
if (isAgentModelId(storedModel)) {
if ($isFreePlan && paidModelIds.includes(storedModel)) {
selectedModel = DEFAULT_PICKER_MODEL_ID;
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, DEFAULT_PICKER_MODEL_ID);
return;
}
selectedModel = storedModel;
}
} catch {
// localStorage unavailable
}
});
</script>
<svelte:head>
<title>Dashboard - ClassroomIO</title>
<title>Home - ClassroomIO</title>
</svelte:head>
<WelcomeModal />
{#if creatingState === 'creating'}
<div class="flex min-h-[80vh] flex-col items-center justify-center gap-6 px-4">
<div class="flex flex-col items-center gap-2 text-center">
<h1 class="text-2xl font-semibold">{$t('course.creator.drafting.heading')}</h1>
<p class="ui:text-muted-foreground text-sm">
{$t('course.creator.drafting.subtext')}
</p>
</div>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t(getGreeting())}
{$profile.fullname}!
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<CreateCourseButton variant="outline" isResponsive />
<div class="ui:border w-full max-w-lg rounded-2xl p-5">
<p class="mb-5 text-sm text-neutral-300 italic">"{draftingPrompt}"</p>
<VisitOrgSiteButton />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="mb-10 flex flex-wrap items-start">
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<ActivityCard
activity={{
icon: AwardIcon,
title: $t('dashboard.certificates_earned'),
percentage: totalCertificates ?? 0,
description: $t('dashboard.certificates_earned_description'),
hidePercentage: true
}}
/>
<ActivityCard
activity={{
icon: BookIcon,
title: $t('dashboard.no_of_courses'),
percentage: numberOfCourses ?? 0,
description: $t('dashboard.no_courses_description'),
hidePercentage: true
}}
/>
<ActivityCard
activity={{
icon: UsersIcon,
title: $t('dashboard.total_students'),
percentage: totalStudents ?? 0,
description: $t('dashboard.total_students_description'),
hidePercentage: true
}}
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<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="flex h-full flex-col">
{#if !stats}
<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>
<div class="flex flex-col gap-3">
{#each steps as step (step.key)}
{@const status = stepStatus(step.key)}
<div class="flex items-center gap-3">
{#if status === 'done'}
<CheckCircleIcon class="h-4 w-4 shrink-0 text-green-500" />
{:else if status === 'active'}
<LoaderCircleIcon class="h-4 w-4 shrink-0 animate-spin text-white" />
{:else}
<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>
</li>
{/each}
</ul>
<CircleIcon class="h-4 w-4 shrink-0 text-neutral-600" />
{/if}
<span
class="text-sm {status === 'done'
? 'text-neutral-400 line-through'
: status === 'active'
? 'ui:text-foreground'
: 'ui:text-muted-foreground'}"
>
{step.label}
</span>
</div>
</div>
<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.recent_certifications')}
</h3>
<div class="h-full space-y-6">
{#if !stats}
{#each Array(5) as _, i (i)}
<Skeleton class="h-10 w-full rounded-md" />
{/each}
{:else}
{#each recentCertifications as certification (`${certification.id}-${certification.courseId}-${certification.date}`)}
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<UserAvatar src={certification.avatarUrl} alt={certification.name ?? 'User'} class="h-6 w-6" />
<div class="min-h-[45px] space-y-1">
<p class="text-sm leading-none capitalize">{certification.name}</p>
<p class="ui:text-muted-foreground text-sm">
<span class="italic">
{calDateDiff(certification.date)}
</span>
</p>
</div>
</div>
<div class="w-2/4">
<a class="hover:underline" href={`/courses/${certification.courseId}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
{certification.course}
</p>
</a>
</div>
</div>
{:else}
<Empty
title={$t('dashboard.no_certifications_yet')}
description={$t('dashboard.no_certifications_yet_description')}
icon={AwardIcon}
class="h-full"
>
<Button variant="outline" onclick={() => goto(`${$currentOrgPath}/courses`)}>
{$t('dashboard.view_courses')}
</Button>
</Empty>
{/each}
{/if}
</div>
</div>
{/each}
</div>
<div class="mt-4">
<LoginActivityChart data={loginActivity} />
</div>
{/snippet}
</Page.Body>
</Page.Root>
</div>
</div>
{:else}
<div class="flex min-h-[80vh] items-center justify-center px-4 py-12">
<div class="w-full max-w-3xl">
<CourseCreator
heading={$t('course.creator.heading')}
placeholder={$t('course.creator.placeholder')}
model={selectedModel}
levelOptions={[
{ value: 'beginner', label: $t('course.creator.level.beginner') },
{ value: 'intermediate', label: $t('course.creator.level.intermediate') },
{ value: 'advanced', label: $t('course.creator.level.advanced') }
]}
lockedModelIds={$isFreePlan ? paidModelIds : []}
onModelChange={handleModelChange}
onLockedModelSelect={handleLockedModelSelect}
onsubmit={handleCreate}
/>
</div>
</div>
{/if}
@@ -0,0 +1,62 @@
import type { DashStatsSuccess, LoginActivityData, LoginActivitySuccess } from '$features/org/utils/types';
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
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;
if (!orgId) {
return {
orgName: siteName,
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],
loginActivity: loginActivityResult.ok ? loginActivityResult.body.data : []
};
}
const [statsResult, loginActivityResult] = await Promise.allSettled([
safeServerApi<DashStatsSuccess>(() =>
classroomio.dash.stats.$get({ query: { siteName } }, getApiHeaders(cookies, orgId))
),
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],
loginActivity: loginActivityDataFromSettled(loginActivityResult)
};
};
@@ -0,0 +1,236 @@
<script lang="ts">
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 UsersIcon from '@lucide/svelte/icons/users';
import { Skeleton } from '@cio/ui/base/skeleton';
import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date';
import { currentOrgPath } from '$lib/utils/store/org';
import { CreateCourseButton } from '$features/course/components';
import { UserAvatar } from '@cio/ui/custom/user-avatar';
import { Progress } from '@cio/ui/base/progress';
import { Button } from '@cio/ui/base/button';
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';
import { profile } from '$lib/utils/store/user';
import { getGreeting } from '$lib/utils/functions/date';
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);
const totalStudents = $derived(stats?.totalStudents || 0);
const topCourses = $derived(stats?.topCourses || []);
</script>
<svelte:head>
<title>Dashboard - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t(getGreeting())}
{$profile.fullname}!
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<CreateCourseButton variant="outline" isResponsive />
<VisitOrgSiteButton />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="mb-10 flex flex-wrap items-start">
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
<ActivityCard
activity={{
icon: AwardIcon,
title: $t('dashboard.certificates_earned'),
percentage: totalCertificates ?? 0,
description: $t('dashboard.certificates_earned_description'),
hidePercentage: true
}}
/>
<ActivityCard
activity={{
icon: BookIcon,
title: $t('dashboard.no_of_courses'),
percentage: numberOfCourses ?? 0,
description: $t('dashboard.no_courses_description'),
hidePercentage: true
}}
/>
<ActivityCard
activity={{
icon: UsersIcon,
title: $t('dashboard.total_students'),
percentage: totalStudents ?? 0,
description: $t('dashboard.total_students_description'),
hidePercentage: true
}}
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<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="flex h-full flex-col">
{#if !stats}
<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}
<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>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<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.recent_certifications')}
</h3>
<div class="h-full space-y-6">
{#if !stats}
{#each Array(5) as _, i (i)}
<Skeleton class="h-10 w-full rounded-md" />
{/each}
{:else}
{#each recentCertifications as certification (`${certification.id}-${certification.courseId}-${certification.date}`)}
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<UserAvatar src={certification.avatarUrl} alt={certification.name ?? 'User'} class="h-6 w-6" />
<div class="min-h-[45px] space-y-1">
<p class="text-sm leading-none capitalize">{certification.name}</p>
<p class="ui:text-muted-foreground text-sm">
<span class="italic">
{calDateDiff(certification.date)}
</span>
</p>
</div>
</div>
<div class="w-2/4">
<a class="hover:underline" href={`/courses/${certification.courseId}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
{certification.course}
</p>
</a>
</div>
</div>
{:else}
<Empty
title={$t('dashboard.no_certifications_yet')}
description={$t('dashboard.no_certifications_yet_description')}
icon={AwardIcon}
class="h-full"
>
<Button variant="outline" onclick={() => goto(`${$currentOrgPath}/courses`)}>
{$t('dashboard.view_courses')}
</Button>
</Empty>
{/each}
{/if}
</div>
</div>
</div>
<div class="mt-4">
<LoginActivityChart data={loginActivity} />
</div>
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,21 @@
<script>
import { AiCreditsPage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>AI Credits - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.ai_credits.sub_title')}</Page.Title>
<Page.Subtitle>{$t('settings.ai_credits.page_subtitle')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<AiCreditsPage />
{/snippet}
</Page.Body>
@@ -19,6 +19,7 @@
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { t } from '$lib/utils/functions/translations';
import { programApi } from '$features/program/api';
import type { OrgCourses } from '$features/course/types';
import AddCourseToProgramModal from '$features/program/components/add-course-to-program-modal.svelte';
import { browser } from '$app/environment';
import { onMount } from 'svelte';
@@ -51,7 +52,7 @@
description: item.course.description ?? '',
lessonCount: 0,
totalStudents: 0,
isPublished: item.course.isPublished ?? false,
isPublished: item.course.status === 'ACTIVE',
tags: []
}))
);
@@ -160,7 +161,7 @@
<p>{item.course.description}</p>
</Table.Cell>
<Table.Cell>
<CoursePublishBadge isPublished={item.course.isPublished ?? false} />
<CoursePublishBadge isPublished={item.course.status === 'ACTIVE'} />
</Table.Cell>
<Table.Cell class="text-center">
<Button
@@ -183,7 +184,7 @@
{:else}
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{#each courseCards as course (course.id)}
<CourseCard {course} href={`/courses/${course.id}`}>
<CourseCard course={course as unknown as OrgCourses[number]} href={`/courses/${course.id}`}>
{#snippet actions()}
<Button
variant="outline"
@@ -14,7 +14,7 @@
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
import PinIcon from '@lucide/svelte/icons/pin';
import { BookIcon } from '@lucide/svelte';
import { buildNextNewsfeedReaction } from '@cio/ui/custom/newsfeed-reactions';
import { buildNextNewsfeedReaction, type NewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
interface Props {
data: {
@@ -75,13 +75,13 @@
await programNewsfeedApi.deleteComment(programId, feedId, commentId);
};
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
const addNewReaction = async (reactionType: NewsfeedReactionType, feedId: string, authorId: string) => {
if (!authorId || isReactingByFeedId[feedId]) return;
const reactedFeed = programNewsfeedApi.feeds.find((feed) => feed.id === feedId);
if (!reactedFeed) return;
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction, reactionType, authorId);
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction ?? undefined, reactionType, authorId);
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };